@cosmicdrift/kumiko-samples 0.370.0 → 0.371.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.370.0",
3
+ "version": "0.371.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.370.0",
3
+ "version": "0.371.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "Boot warning when anonymous routes run without anonymousAccess",
6
+ "detail": "A boot warning names anonymousAccess when host-routing, managed-pages or legal-pages is mounted without it; anonymous requests get no tenant until the option is set."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "breaking",
@@ -11,9 +11,15 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { resolveTenantExistence, resolveTenantResolver } from "./feature.ts";
12
12
  import type { AuthProviderBuildDeps } from "./types.ts";
13
13
 
14
+ // Features whose routes are anonymous and need an `anonymousAccess` entry to resolve a tenant.
15
+ const ANONYMOUS_ROUTE_FEATURES = ["host-routing", "managed-pages", "legal-pages"] as const;
16
+
14
17
  export async function resolveAnonymousAccessFromRegistry(
15
18
  base: AnonymousAccessResolved | AnonymousAccessConfig | undefined,
16
- deps: AuthProviderBuildDeps & { readonly registry: Registry },
19
+ deps: AuthProviderBuildDeps & {
20
+ readonly registry: Registry;
21
+ readonly warn?: (message: string) => void;
22
+ },
17
23
  ): Promise<AnonymousAccessResolved | undefined> {
18
24
  const [tenantResolver, tenantExists] = await Promise.all([
19
25
  resolveTenantResolver(deps),
@@ -25,7 +31,16 @@ export async function resolveAnonymousAccessFromRegistry(
25
31
  // anonymousAccess become defined for an app that never set it — that
26
32
  // would upgrade every roles:["anonymous"] handler to reachable without
27
33
  // a JWT the moment such a routing feature is mounted (#1452).
28
- if (!base) return undefined;
34
+ if (!base) {
35
+ const needing = ANONYMOUS_ROUTE_FEATURES.filter((name) => deps.registry.getFeature(name));
36
+ if (needing.length > 0) {
37
+ deps.warn?.(
38
+ `[auth-foundation] ${needing.join(", ")} mount${needing.length === 1 ? "s" : ""} anonymous routes but anonymousAccess is not set, ` +
39
+ "so anonymous requests get no tenant. Add `anonymousAccess: {}` to opt in.",
40
+ );
41
+ }
42
+ return undefined;
43
+ }
29
44
 
30
45
  // Fail-closed (#1374 security): inline tenantResolver without trust is the
31
46
  // old ambiguous config — providers always carry trust; test stacks must too.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "Billing webhook sync exposes afterSync",
6
+ "detail": "The billing webhook sync calls afterSync({ tenantId, tier, changed, event }) after every webhook, with changed false when the tier stayed the same."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "breaking",
@@ -79,6 +79,7 @@ export {
79
79
  effectiveTierFromSubscription,
80
80
  SUBSCRIPTION_WEBHOOK_PATH,
81
81
  type SubscriptionTierSyncDeps,
82
+ type TierSyncResult,
82
83
  } from "./subscription-tier-sync.ts";
83
84
  export {
84
85
  type BillingFoundationOptions,
@@ -19,6 +19,7 @@ import type { TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine
19
19
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
20
20
  import { subscriptionAggregateId } from "./aggregate-id.ts";
21
21
  import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.ts";
22
+ import type { PaymentEvent, SubscriptionEvent } from "./types.ts";
22
23
  import { createSubscriptionWebhookRoute } from "./webhook-handler.ts";
23
24
 
24
25
  // Outside /api — signature routes carry their own auth (verify()) and are
@@ -38,8 +39,30 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
38
39
  // idempotent caller (e.g. Stripe, whose retry re-runs the already-committed
39
40
  // primary write as a no-op) gets a second attempt at the sync step itself.
40
41
  readonly onSyncError?: "log" | "fail-webhook";
42
+ /** Runs after every webhook whose sync succeeded, also when the tier stayed the same
43
+ * (`changed: false`). Not called when the tenant has no subscription yet, when a
44
+ * manual grant was kept, or when the sync failed. A throw counts as a sync failure
45
+ * and follows `onSyncError`. */
46
+ readonly afterSync?: (result: TierSyncResult<TTier>) => Promise<void>;
41
47
  };
42
48
 
49
+ export type TierSyncResult<TTier extends string> = {
50
+ readonly tenantId: TenantId;
51
+ /** The tier the subscription yields. */
52
+ readonly tier: TTier;
53
+ /** True when this sync created or updated the tenant's tier assignment. */
54
+ readonly changed: boolean;
55
+ readonly event: SubscriptionEvent | PaymentEvent;
56
+ };
57
+
58
+ type SyncError = { code: string; message: string };
59
+ type SyncOutcome<TTier extends string> =
60
+ | { readonly error: SyncError }
61
+ | {
62
+ readonly error: null;
63
+ readonly applied?: { readonly tier: TTier; readonly changed: boolean };
64
+ };
65
+
43
66
  // A manual grant that differs from what billing would assign is reported with this
44
67
  // code but never fails the webhook: a provider retry cannot resolve it.
45
68
  export const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
@@ -92,10 +115,10 @@ function findTierAssignment(
92
115
  export function createSubscriptionTierSync<TTier extends string>(
93
116
  deps: SubscriptionTierSyncDeps<TTier>,
94
117
  ) {
95
- async function syncTierFromSubscription(
118
+ async function runTierSync(
96
119
  tenantId: TenantId,
97
120
  routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
98
- ): Promise<{ code: string; message: string } | null> {
121
+ ): Promise<SyncOutcome<TTier>> {
99
122
  try {
100
123
  const subscriptionId = subscriptionAggregateId(tenantId);
101
124
  const subscriptionRows = asRows(
@@ -106,7 +129,7 @@ export function createSubscriptionTierSync<TTier extends string>(
106
129
  }),
107
130
  );
108
131
  const sub = subscriptionRows.find((row) => row["id"] === subscriptionId);
109
- if (!sub) return null;
132
+ if (!sub) return { error: null };
110
133
 
111
134
  const effective = effectiveTierFromSubscription(
112
135
  typeof sub["status"] === "string" ? sub["status"] : undefined,
@@ -135,17 +158,23 @@ export function createSubscriptionTierSync<TTier extends string>(
135
158
  });
136
159
  if (!created.isSuccess) {
137
160
  return {
138
- code: "tier_sync_failed",
139
- message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
161
+ error: {
162
+ code: "tier_sync_failed",
163
+ message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
164
+ },
140
165
  };
141
166
  }
142
- return null;
167
+ return { error: null, applied: { tier: effective, changed: true } };
168
+ }
169
+ if (assignment.tier === effective) {
170
+ return { error: null, applied: { tier: effective, changed: false } };
143
171
  }
144
- if (assignment.tier === effective) return null;
145
172
  if (assignment.source === TierAssignmentSources.manual) {
146
173
  return {
147
- code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
148
- message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
174
+ error: {
175
+ code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
176
+ message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
177
+ },
149
178
  };
150
179
  }
151
180
 
@@ -160,11 +189,13 @@ export function createSubscriptionTierSync<TTier extends string>(
160
189
  });
161
190
  if (!result.isSuccess) {
162
191
  return {
163
- code: "tier_sync_failed",
164
- message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
192
+ error: {
193
+ code: "tier_sync_failed",
194
+ message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
195
+ },
165
196
  };
166
197
  }
167
- return null;
198
+ return { error: null, applied: { tier: effective, changed: true } };
168
199
  } catch (error) {
169
200
  // dispatchSystemQuery throws (AccessDenied/NotFound/Validation) rather
170
201
  // than returning an error envelope — e.g. a consumer that mounts
@@ -172,8 +203,37 @@ export function createSubscriptionTierSync<TTier extends string>(
172
203
  // a failed dispatchSystemWrite instead of letting it bubble into the
173
204
  // webhook response as an unhandled 500.
174
205
  return {
175
- code: "tier_sync_failed",
176
- message: error instanceof Error ? error.message : "tier sync threw a non-Error value",
206
+ error: {
207
+ code: "tier_sync_failed",
208
+ message: error instanceof Error ? error.message : "tier sync threw a non-Error value",
209
+ },
210
+ };
211
+ }
212
+ }
213
+
214
+ async function syncTierFromSubscription(
215
+ tenantId: TenantId,
216
+ routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
217
+ ): Promise<SyncError | null> {
218
+ return (await runTierSync(tenantId, routeDeps)).error;
219
+ }
220
+
221
+ async function syncAndNotify(
222
+ tenantId: TenantId,
223
+ event: SubscriptionEvent | PaymentEvent,
224
+ routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
225
+ ): Promise<SyncError | null> {
226
+ const outcome = await runTierSync(tenantId, routeDeps);
227
+ if (outcome.error !== null || outcome.applied === undefined || !deps.afterSync) {
228
+ return outcome.error;
229
+ }
230
+ try {
231
+ await deps.afterSync({ tenantId, event, ...outcome.applied });
232
+ return null;
233
+ } catch (error) {
234
+ return {
235
+ code: "tier_sync_after_hook_failed",
236
+ message: error instanceof Error ? error.message : "afterSync threw a non-Error value",
177
237
  };
178
238
  }
179
239
  }
@@ -182,8 +242,13 @@ export function createSubscriptionTierSync<TTier extends string>(
182
242
  function createWebhookRoute(options: { readonly path?: string } = {}): ExtraRouteDefinition {
183
243
  return createSubscriptionWebhookRoute({
184
244
  path: options.path ?? SUBSCRIPTION_WEBHOOK_PATH,
185
- afterDispatch: async (dispatched: WriteResult, tenantId, routeDeps): Promise<WriteResult> => {
186
- const syncError = await syncTierFromSubscription(tenantId, routeDeps);
245
+ afterDispatch: async (
246
+ dispatched: WriteResult,
247
+ tenantId,
248
+ routeDeps,
249
+ event,
250
+ ): Promise<WriteResult> => {
251
+ const syncError = await syncAndNotify(tenantId, event, routeDeps);
187
252
  if (syncError) {
188
253
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
189
254
  console.warn(
@@ -209,5 +274,5 @@ export function createSubscriptionTierSync<TTier extends string>(
209
274
  });
210
275
  }
211
276
 
212
- return { createWebhookRoute, syncTierFromSubscription };
277
+ return { createWebhookRoute, syncTierFromSubscription, syncAndNotify };
213
278
  }
@@ -27,6 +27,7 @@ export type SubscriptionWebhookRouteOptions = {
27
27
  dispatched: import("@cosmicdrift/kumiko-framework/engine").WriteResult,
28
28
  tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId,
29
29
  deps: import("@cosmicdrift/kumiko-framework/api").SignatureExtraRouteDeps,
30
+ event: SubscriptionEvent | PaymentEvent,
30
31
  ) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
31
32
  };
32
33
 
@@ -113,7 +114,7 @@ export function createSubscriptionWebhookRoute(options: SubscriptionWebhookRoute
113
114
  return respondFromDispatch(
114
115
  c,
115
116
  dispatched.isSuccess && options.afterDispatch
116
- ? await options.afterDispatch(dispatched, tenantId, deps)
117
+ ? await options.afterDispatch(dispatched, tenantId, deps, parsed)
117
118
  : dispatched,
118
119
  "subscription_payment_webhook_processing_failed",
119
120
  "Internal error processing payment event",
@@ -138,7 +139,7 @@ export function createSubscriptionWebhookRoute(options: SubscriptionWebhookRoute
138
139
  return respondFromDispatch(
139
140
  c,
140
141
  dispatched.isSuccess && options.afterDispatch
141
- ? await options.afterDispatch(dispatched, tenantId, deps)
142
+ ? await options.afterDispatch(dispatched, tenantId, deps, parsed)
142
143
  : dispatched,
143
144
  "subscription_webhook_processing_failed",
144
145
  "Internal error processing subscription event",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "Unsubscribe endpoint answers form posts with an HTML page",
6
+ "detail": "POST on the delivery unsubscribe route returns an HTML confirmation page for a form post and JSON for a bare POST. The behavior is unchanged; this only documents it.",
7
+ "migration": "Clients that post the unsubscribe form expect HTML, API clients that post without a form content type expect JSON. No code change is needed."
8
+ },
2
9
  {
3
10
  "version": "0.370.0",
4
11
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.371.0",
4
+ "type": "improvement",
5
+ "title": "S3 endpoint over http outside production",
6
+ "detail": "s3EndpointSchema rejects http on non-loopback hosts only when NODE_ENV=production, so compose hostnames such as minio work in smoke setups."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -2,18 +2,20 @@ import * as z from "zod";
2
2
 
3
3
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
4
 
5
- // The bucket holds tenant files, so a remote endpoint must be https; plain
6
- // http stays allowed for a loopback MinIO in dev and CI.
7
- function isHttpsOrLoopbackHttp(value: string): boolean {
5
+ // The bucket holds tenant files, so in production a remote endpoint must be
6
+ // https. Outside production plain http stays allowed, also for compose-network
7
+ // hostnames (minio, rustfs) that are not loopback.
8
+ function isAcceptableEndpoint(value: string): boolean {
8
9
  if (!URL.canParse(value)) return false;
9
10
  const url = new URL(value);
10
- return url.protocol === "https:" || LOOPBACK_HOSTNAMES.has(url.hostname);
11
+ if (url.protocol === "https:") return true;
12
+ return process.env["NODE_ENV"] !== "production" || LOOPBACK_HOSTNAMES.has(url.hostname);
11
13
  }
12
14
 
13
- /** S3-compatible endpoint URL: https, or http on a loopback host. */
15
+ /** S3-compatible endpoint URL: https, or http on a loopback host; any http host outside `NODE_ENV=production`. */
14
16
  export const s3EndpointSchema = z
15
17
  .url({ protocol: /^https?$/ })
16
- .refine(isHttpsOrLoopbackHttp, "must use https unless the host is loopback");
18
+ .refine(isAcceptableEndpoint, "must use https unless the host is loopback");
17
19
 
18
20
  /** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
19
21
  * `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.