@kedge/server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@kedge/server",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/chandeldivyam/Kedge.git",
8
8
  "directory": "packages/server"
9
9
  },
10
+ "engines": {
11
+ "node": ">=24"
12
+ },
10
13
  "type": "module",
11
14
  "description": "Hono app: /store, /admin, /health.",
12
15
  "exports": {
@@ -19,8 +22,8 @@
19
22
  "hono": "4.13.13",
20
23
  "@hono/zod-openapi": "1.6.3",
21
24
  "zod": "4.6.7",
22
- "@kedge/core": "0.9.0",
23
- "@kedge/plugin-sdk": "0.9.0"
25
+ "@kedge/core": "0.10.0",
26
+ "@kedge/plugin-sdk": "0.10.0"
24
27
  },
25
28
  "devDependencies": {
26
29
  "@cloudflare/workers-types": "5.20261024.1"
package/src/access.ts CHANGED
@@ -307,6 +307,11 @@ export function adminGate(auth: AuthDeps, perms: AdminPermissions, now: () => nu
307
307
  if (cred?.via === "bearer" && cred.token.startsWith("sk_")) {
308
308
  const found = await resolveKey(c, auth, cred.token, t);
309
309
  access = { ctx: found.ctx, via: "key", key: found.key, resolved: found };
310
+ } else if (cred?.via === "bearer" && cred.token.startsWith("kcs_")) {
311
+ // A CLI session (p0.10 §6.2): admitted here, on /admin/* only, never as a staff session.
312
+ const found = await auth.access.cliSession(cred.token, t);
313
+ if (!found) throw new KedgeError("UNAUTHORIZED", "This CLI session has ended; run the command again");
314
+ access = { ctx: found.ctx, via: "bearer", resolved: found };
310
315
  } else if (cred) {
311
316
  access = await resolveSession(c, auth, "staff", cred, t);
312
317
  } else {
@@ -485,14 +485,18 @@ export function adminRoutes(
485
485
  createRoute({
486
486
  method: "put",
487
487
  path: "/admin/storefronts/{id}/origins",
488
- request: { params: z.object({ id: ID }), ...jsonBody(z.object({ allowed_origins: Origins })) },
488
+ request: {
489
+ params: z.object({ id: ID }),
490
+ // `expected`: the list as the caller read it; the write is refused CONFLICT if it changed since.
491
+ ...jsonBody(z.object({ allowed_origins: Origins, expected: Origins.optional() })),
492
+ },
489
493
  responses: ok("The storefront's origins"),
490
494
  }),
491
495
  ),
492
496
  async (c) => {
493
497
  const { id } = c.req.valid("param");
494
- const { allowed_origins } = c.req.valid("json");
495
- await admin.setOrigins(ctx(c), id, allowed_origins, req(c));
498
+ const { allowed_origins, expected } = c.req.valid("json");
499
+ await admin.setOrigins(ctx(c), id, allowed_origins, req(c), expected);
496
500
  return c.json({ id, allowed_origins }, 200);
497
501
  },
498
502
  );
@@ -575,6 +579,78 @@ export function adminRoutes(
575
579
  },
576
580
  );
577
581
 
582
+ // ---- credential references (p0.10 §6.2) ----------------------------------------------
583
+ // The permission depends on the ref's kind (api_key:write for a secret key,
584
+ // storefront:write otherwise), so the store authorizes inside its own batch.
585
+
586
+ const Ref = z.object({ ref: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/) });
587
+ const RefKind = z.enum(["secret", "publishable", "storefront"]);
588
+ const refJson = (r: {
589
+ ref: string;
590
+ kind: string;
591
+ state: string;
592
+ storefrontId: string | null;
593
+ keyId: string | null;
594
+ }) => ({ ref: r.ref, kind: r.kind, state: r.state, storefront_id: r.storefrontId, key_id: r.keyId });
595
+ app.openapi(
596
+ perms.declare(
597
+ { permission: "api_key:write", store: true },
598
+ createRoute({
599
+ method: "put",
600
+ path: "/admin/credential-refs/{ref}",
601
+ request: {
602
+ params: Ref,
603
+ ...jsonBody(
604
+ z.discriminatedUnion("kind", [
605
+ z.object({
606
+ kind: z.literal("secret"),
607
+ name: z.string().min(1).max(80),
608
+ grants: Grants.min(1),
609
+ location_ids: z.array(ID).max(64).optional(),
610
+ }),
611
+ z.object({ kind: z.literal("publishable"), name: z.string().min(1).max(80), storefront_id: ID }),
612
+ z.object({ kind: z.literal("storefront"), name: z.string().min(1).max(80), allowed_origins: Origins }),
613
+ ]),
614
+ ),
615
+ },
616
+ responses: ok("The key the ref made, shown once; CREDENTIAL_REF_TAKEN if it was used or voided"),
617
+ }),
618
+ ),
619
+ async (c) => {
620
+ once(c);
621
+ const b = c.req.valid("json");
622
+ const made = await admin.createByRef(
623
+ ctx(c),
624
+ c.req.valid("param").ref,
625
+ b.kind === "secret"
626
+ ? {
627
+ kind: "secret",
628
+ name: b.name,
629
+ grants: grants(b.grants),
630
+ ...(b.location_ids ? { locationIds: b.location_ids } : {}),
631
+ }
632
+ : b.kind === "publishable"
633
+ ? { kind: "publishable", name: b.name, storefrontId: b.storefront_id }
634
+ : { kind: "storefront", name: b.name, allowedOrigins: b.allowed_origins },
635
+ req(c),
636
+ );
637
+ return c.json({ ...refJson(made), key: made.key, token_prefix: made.tokenPrefix }, 200);
638
+ },
639
+ );
640
+ app.openapi(
641
+ perms.declare(
642
+ { permission: "api_key:write", store: true },
643
+ createRoute({
644
+ method: "post",
645
+ path: "/admin/credential-refs/{ref}/void",
646
+ request: { params: Ref, ...jsonBody(z.object({ kind: RefKind })) },
647
+ responses: ok("The ref's outcome, kept for ever: fenced, or revoked with what it made"),
648
+ }),
649
+ ),
650
+ async (c) =>
651
+ c.json(refJson(await admin.voidRef(ctx(c), c.req.valid("param").ref, c.req.valid("json").kind, req(c))), 200),
652
+ );
653
+
578
654
  // ---- API keys ----------------------------------------------------------------------
579
655
 
580
656
  const KeyParam = z.object({ id: ID });
@@ -184,22 +184,26 @@ export function entityRoutes(
184
184
  method: "post",
185
185
  path: `${base}/search`,
186
186
  request: jsonBody(
187
- z.object({
188
- filters: z
189
- .array(z.object({ field: z.string(), op: z.enum(["eq", "in", "gte", "lt"]), value: z.unknown() }))
190
- .max(16)
191
- .optional(),
192
- search: z
193
- .object({
194
- fields: z.array(z.string()).min(1).max(ENTITY_LIMITS.SEARCH_FIELDS_MAX),
195
- term: z.string().min(1).max(200),
196
- })
197
- .optional(),
198
- sort: z.string().max(64).optional(),
199
- state: z.enum(["live", "archived"]).optional(),
200
- cursor: z.string().max(2048).optional(),
201
- limit: z.number().int().min(1).max(100).optional(),
202
- }),
187
+ z
188
+ .object({
189
+ filters: z
190
+ .array(z.object({ field: z.string(), op: z.enum(["eq", "in", "gte", "lt"]), value: z.unknown() }))
191
+ .max(16)
192
+ .optional(),
193
+ search: z
194
+ .object({
195
+ fields: z.array(z.string()).min(1).max(ENTITY_LIMITS.SEARCH_FIELDS_MAX),
196
+ term: z.string().min(1).max(200),
197
+ })
198
+ .optional(),
199
+ sort: z.string().max(64).optional(),
200
+ state: z.enum(["live", "archived"]).optional(),
201
+ cursor: z.string().max(2048).optional(),
202
+ limit: z.number().int().min(1).max(100).optional(),
203
+ })
204
+ // A key it doesn't know is refused, naming it, never ignored: a mistyped `filter`
205
+ // would otherwise answer every row as if it had narrowed them (D-068).
206
+ .strict(),
203
207
  ),
204
208
  responses: ok(`A page of ${def.label}, classified values in the body`),
205
209
  }),
package/src/types.ts CHANGED
@@ -231,6 +231,8 @@ export interface AccessStore {
231
231
  /** Staff activity or a customer renewal; the renewed expiry, if any. */
232
232
  maintain(session: AccessSession, now: number): Promise<number | null>;
233
233
  apiKey(presented: string, now: number): Promise<ResolvedAccess | null>;
234
+ /** A CLI session's principal (p0.10 §6.2), or null when the token names no unexpired one. */
235
+ cliSession(token: string, now: number): Promise<ResolvedAccess | null>;
234
236
  keyTouchDue(key: { lastUsedAt: number | null }, now: number): boolean;
235
237
  touchKey(keyId: string, now: number): Promise<void>;
236
238
  /** Counts a failed key verification against the address; false once past the limit (§4.3). */
@@ -365,6 +367,20 @@ export interface IssuedInvite {
365
367
  token: string;
366
368
  expiresAt: number;
367
369
  }
370
+ /** What a credential reference made, kept for ever (p0.10 §6.2). */
371
+ export interface CredentialRefState {
372
+ ref: string;
373
+ kind: "secret" | "publishable" | "storefront";
374
+ state: "used" | "fenced" | "revoked";
375
+ storefrontId: string | null;
376
+ keyId: string | null;
377
+ }
378
+
379
+ export type CredentialRefCreate =
380
+ | { kind: "secret"; name: string; grants: readonly GrantInput[]; locationIds?: readonly string[] }
381
+ | { kind: "publishable"; name: string; storefrontId: string }
382
+ | { kind: "storefront"; name: string; allowedOrigins: readonly string[] };
383
+
368
384
  export interface IssuedKey {
369
385
  id: string;
370
386
  key: string;
@@ -443,6 +459,8 @@ export interface AdminStore {
443
459
  storefrontId: string,
444
460
  allowedOrigins: readonly string[],
445
461
  req?: AdminRequest,
462
+ /** The list the caller read: the write is refused CONFLICT if it changed since. */
463
+ expected?: readonly string[],
446
464
  ): Promise<void>;
447
465
  createSecretKey(
448
466
  ctx: AccessContext,
@@ -461,6 +479,20 @@ export interface AdminStore {
461
479
  req?: AdminRequest,
462
480
  storefrontId?: string,
463
481
  ): Promise<{ id: string; revokedAt: number | null }>;
482
+ /** A key made under a caller-chosen ref: at most one, ever (p0.10 §6.2). */
483
+ createByRef(
484
+ ctx: AccessContext,
485
+ ref: string,
486
+ input: CredentialRefCreate,
487
+ req?: AdminRequest,
488
+ ): Promise<CredentialRefState & IssuedKey>;
489
+ /** Settles a ref for good: fenced when unused, its key revoked when used. */
490
+ voidRef(
491
+ ctx: AccessContext,
492
+ ref: string,
493
+ kind: CredentialRefState["kind"],
494
+ req?: AdminRequest,
495
+ ): Promise<CredentialRefState>;
464
496
  rotateKey(
465
497
  ctx: AccessContext,
466
498
  keyId: string,