Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fair-otters-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@emdash-cms/plugin-cli": patch
---

Fixes saved OAuth sessions failing to refresh or revoke after the original loopback callback server closes. New logins retain the loopback client registration needed to recreate the same OAuth client.

Sessions created before this fix do not contain that registration metadata and cannot be resumed. Sign in again after upgrading.
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read
Expand Down
11 changes: 11 additions & 0 deletions apps/release-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Public HTTPS origin used as the confidential OAuth client ID.
PUBLIC_ORIGIN=https://release.example.com

# JSON array containing exactly PUBLIC_ORIGIN/oauth/callback.
OAUTH_REDIRECT_URIS=["https://release.example.com/oauth/callback"]

# OAUTH_ASSERTION_KEYSET is secret key material. Set it in .env for local
# development and through a secret binding in deployed environments.

# ENCRYPTION_KEYRING is a versioned JSON keyring containing 32-byte base64url
# master keys. Keep it separate from OAuth assertion keys.
30 changes: 30 additions & 0 deletions apps/release-service/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@emdash-cms/release-service",
"version": "0.0.0",
"private": true,
"description": "Cloudflare Worker for delegated EmDash registry releases.",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"deploy": "vite build && wrangler deploy",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"types": "wrangler types"
},
"dependencies": {
"@atcute/oauth-node-client": "catalog:",
"@emdash-cms/registry-lexicons": "workspace:*",
"jose": "^6.1.3"
},
"devDependencies": {
"@cloudflare/vite-plugin": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
28 changes: 28 additions & 0 deletions apps/release-service/src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type ApiErrorCode =
| "CONFIGURATION_ERROR"
| "INTERNAL_ERROR"
| "NOT_FOUND"
| "METHOD_NOT_ALLOWED";

export interface SerializedApiError {
code: ApiErrorCode;
message: string;
}

export class ApiError extends Error {
readonly code: ApiErrorCode;
readonly status: number;

constructor(code: ApiErrorCode, status: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
this.status = status;
}
}

export function serializeApiError(error: unknown): SerializedApiError {
return error instanceof ApiError
? { code: error.code, message: error.message }
: { code: "INTERNAL_ERROR", message: "Internal server error" };
}
6 changes: 6 additions & 0 deletions apps/release-service/src/api/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;

export function getRequestId(request: Request): string {
const supplied = request.headers.get("x-request-id");
return supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : crypto.randomUUID();
}
22 changes: 22 additions & 0 deletions apps/release-service/src/api/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiError, serializeApiError } from "./errors.js";

const JSON_HEADERS = {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
} as const;

export function apiSuccess<T>(data: T, requestId: string, status = 200): Response {
return Response.json(
{ data, requestId },
{ status, headers: { ...JSON_HEADERS, "x-request-id": requestId } },
);
}

export function apiFailure(error: unknown, requestId: string): Response {
const status = error instanceof ApiError ? error.status : 500;
return Response.json(
{ error: serializeApiError(error), requestId },
{ status, headers: { ...JSON_HEADERS, "x-request-id": requestId } },
);
}
Loading
Loading