@cosmicdrift/kumiko-bundled-features 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/dist/auth-foundation/resolve-anonymous-access.d.ts +1 -0
- package/dist/auth-foundation/resolve-anonymous-access.js +9 -1
- package/dist/billing-foundation/index.d.ts +1 -1
- package/dist/billing-foundation/subscription-tier-sync.d.ts +21 -4
- package/dist/billing-foundation/subscription-tier-sync.js +45 -17
- package/dist/billing-foundation/webhook-handler.d.ts +2 -1
- package/dist/billing-foundation/webhook-handler.js +2 -2
- package/dist/file-provider-s3/env-schema.d.ts +1 -1
- package/dist/file-provider-s3/env-schema.js +9 -6
- package/package.json +11 -11
- package/src/auth-foundation/changes.json +6 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/delivery/changes.json +7 -0
- package/src/file-provider-s3/changes.json +6 -0
|
@@ -8,4 +8,5 @@ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
8
8
|
import type { AuthProviderBuildDeps } from "./types.ts";
|
|
9
9
|
export declare function resolveAnonymousAccessFromRegistry(base: AnonymousAccessResolved | AnonymousAccessConfig | undefined, deps: AuthProviderBuildDeps & {
|
|
10
10
|
readonly registry: Registry;
|
|
11
|
+
readonly warn?: (message: string) => void;
|
|
11
12
|
}): Promise<AnonymousAccessResolved | undefined>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { resolveTenantExistence, resolveTenantResolver } from "./feature.js";
|
|
2
|
+
// Features whose routes are anonymous and need an `anonymousAccess` entry to resolve a tenant.
|
|
3
|
+
const ANONYMOUS_ROUTE_FEATURES = ["host-routing", "managed-pages", "legal-pages"];
|
|
2
4
|
export async function resolveAnonymousAccessFromRegistry(base, deps) {
|
|
3
5
|
const [tenantResolver, tenantExists] = await Promise.all([
|
|
4
6
|
resolveTenantResolver(deps),
|
|
@@ -9,8 +11,14 @@ export async function resolveAnonymousAccessFromRegistry(base, deps) {
|
|
|
9
11
|
// anonymousAccess become defined for an app that never set it — that
|
|
10
12
|
// would upgrade every roles:["anonymous"] handler to reachable without
|
|
11
13
|
// a JWT the moment such a routing feature is mounted (#1452).
|
|
12
|
-
if (!base)
|
|
14
|
+
if (!base) {
|
|
15
|
+
const needing = ANONYMOUS_ROUTE_FEATURES.filter((name) => deps.registry.getFeature(name));
|
|
16
|
+
if (needing.length > 0) {
|
|
17
|
+
deps.warn?.(`[auth-foundation] ${needing.join(", ")} mount${needing.length === 1 ? "s" : ""} anonymous routes but anonymousAccess is not set, ` +
|
|
18
|
+
"so anonymous requests get no tenant. Add `anonymousAccess: {}` to opt in.");
|
|
19
|
+
}
|
|
13
20
|
return undefined;
|
|
21
|
+
}
|
|
14
22
|
// Fail-closed (#1374 security): inline tenantResolver without trust is the
|
|
15
23
|
// old ambiguous config — providers always carry trust; test stacks must too.
|
|
16
24
|
if (!tenantResolver &&
|
|
@@ -11,6 +11,6 @@ export { createBillingFoundationFeature } from "./feature.ts";
|
|
|
11
11
|
export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant.ts";
|
|
12
12
|
export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.ts";
|
|
13
13
|
export { billingPlansPanel, createBillingPlansScreen } from "./screens.ts";
|
|
14
|
-
export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, } from "./subscription-tier-sync.ts";
|
|
14
|
+
export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, type TierSyncResult, } from "./subscription-tier-sync.ts";
|
|
15
15
|
export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.ts";
|
|
16
16
|
export { createSubscriptionWebhookRoute, type SubscriptionWebhookRouteOptions, } from "./webhook-handler.ts";
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import type { ExtraRouteDefinition, SignatureExtraRouteDeps } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { PaymentEvent, SubscriptionEvent } from "./types.ts";
|
|
3
4
|
export declare const SUBSCRIPTION_WEBHOOK_PATH = "/webhooks/subscription/:providerName";
|
|
4
5
|
export type SubscriptionTierSyncDeps<TTier extends string> = {
|
|
5
6
|
readonly isTierName: (value: string) => value is TTier;
|
|
6
7
|
readonly defaultTier: TTier;
|
|
7
8
|
readonly onSyncError?: "log" | "fail-webhook";
|
|
9
|
+
/** Runs after every webhook whose sync succeeded, also when the tier stayed the same
|
|
10
|
+
* (`changed: false`). Not called when the tenant has no subscription yet, when a
|
|
11
|
+
* manual grant was kept, or when the sync failed. A throw counts as a sync failure
|
|
12
|
+
* and follows `onSyncError`. */
|
|
13
|
+
readonly afterSync?: (result: TierSyncResult<TTier>) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
export type TierSyncResult<TTier extends string> = {
|
|
16
|
+
readonly tenantId: TenantId;
|
|
17
|
+
/** The tier the subscription yields. */
|
|
18
|
+
readonly tier: TTier;
|
|
19
|
+
/** True when this sync created or updated the tenant's tier assignment. */
|
|
20
|
+
readonly changed: boolean;
|
|
21
|
+
readonly event: SubscriptionEvent | PaymentEvent;
|
|
22
|
+
};
|
|
23
|
+
type SyncError = {
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
8
26
|
};
|
|
9
27
|
export declare const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
|
|
10
28
|
export declare function effectiveTierFromSubscription<TTier extends string>(status: string | undefined, tier: string | undefined, isTierName: (value: string) => value is TTier, defaultTier: TTier): TTier;
|
|
@@ -12,8 +30,7 @@ export declare function createSubscriptionTierSync<TTier extends string>(deps: S
|
|
|
12
30
|
createWebhookRoute: (options?: {
|
|
13
31
|
readonly path?: string;
|
|
14
32
|
}) => ExtraRouteDefinition;
|
|
15
|
-
syncTierFromSubscription: (tenantId: TenantId, routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">) => Promise<
|
|
16
|
-
|
|
17
|
-
message: string;
|
|
18
|
-
} | null>;
|
|
33
|
+
syncTierFromSubscription: (tenantId: TenantId, routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">) => Promise<SyncError | null>;
|
|
34
|
+
syncAndNotify: (tenantId: TenantId, event: SubscriptionEvent | PaymentEvent, routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">) => Promise<SyncError | null>;
|
|
19
35
|
};
|
|
36
|
+
export {};
|
|
@@ -41,7 +41,7 @@ function findTierAssignment(rows, tenantId) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
export function createSubscriptionTierSync(deps) {
|
|
44
|
-
async function
|
|
44
|
+
async function runTierSync(tenantId, routeDeps) {
|
|
45
45
|
try {
|
|
46
46
|
const subscriptionId = subscriptionAggregateId(tenantId);
|
|
47
47
|
const subscriptionRows = asRows(await routeDeps.dispatchSystemQuery({
|
|
@@ -51,7 +51,7 @@ export function createSubscriptionTierSync(deps) {
|
|
|
51
51
|
}));
|
|
52
52
|
const sub = subscriptionRows.find((row) => row["id"] === subscriptionId);
|
|
53
53
|
if (!sub)
|
|
54
|
-
return null;
|
|
54
|
+
return { error: null };
|
|
55
55
|
const effective = effectiveTierFromSubscription(typeof sub["status"] === "string" ? sub["status"] : undefined, typeof sub["tier"] === "string" ? sub["tier"] : undefined, deps.isTierName, deps.defaultTier);
|
|
56
56
|
const tierAssignmentRows = asRows(await routeDeps.dispatchSystemQuery({
|
|
57
57
|
handlerQn: TierEngineQueries.list,
|
|
@@ -71,18 +71,23 @@ export function createSubscriptionTierSync(deps) {
|
|
|
71
71
|
});
|
|
72
72
|
if (!created.isSuccess) {
|
|
73
73
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
error: {
|
|
75
|
+
code: "tier_sync_failed",
|
|
76
|
+
message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
|
|
77
|
+
},
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
|
-
return null;
|
|
80
|
+
return { error: null, applied: { tier: effective, changed: true } };
|
|
81
|
+
}
|
|
82
|
+
if (assignment.tier === effective) {
|
|
83
|
+
return { error: null, applied: { tier: effective, changed: false } };
|
|
79
84
|
}
|
|
80
|
-
if (assignment.tier === effective)
|
|
81
|
-
return null;
|
|
82
85
|
if (assignment.source === TierAssignmentSources.manual) {
|
|
83
86
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
error: {
|
|
88
|
+
code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
|
|
89
|
+
message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
|
|
90
|
+
},
|
|
86
91
|
};
|
|
87
92
|
}
|
|
88
93
|
const result = await routeDeps.dispatchSystemWrite({
|
|
@@ -96,11 +101,13 @@ export function createSubscriptionTierSync(deps) {
|
|
|
96
101
|
});
|
|
97
102
|
if (!result.isSuccess) {
|
|
98
103
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
error: {
|
|
105
|
+
code: "tier_sync_failed",
|
|
106
|
+
message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
|
|
107
|
+
},
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
|
-
return null;
|
|
110
|
+
return { error: null, applied: { tier: effective, changed: true } };
|
|
104
111
|
}
|
|
105
112
|
catch (error) {
|
|
106
113
|
// dispatchSystemQuery throws (AccessDenied/NotFound/Validation) rather
|
|
@@ -109,8 +116,29 @@ export function createSubscriptionTierSync(deps) {
|
|
|
109
116
|
// a failed dispatchSystemWrite instead of letting it bubble into the
|
|
110
117
|
// webhook response as an unhandled 500.
|
|
111
118
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
error: {
|
|
120
|
+
code: "tier_sync_failed",
|
|
121
|
+
message: error instanceof Error ? error.message : "tier sync threw a non-Error value",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function syncTierFromSubscription(tenantId, routeDeps) {
|
|
127
|
+
return (await runTierSync(tenantId, routeDeps)).error;
|
|
128
|
+
}
|
|
129
|
+
async function syncAndNotify(tenantId, event, routeDeps) {
|
|
130
|
+
const outcome = await runTierSync(tenantId, routeDeps);
|
|
131
|
+
if (outcome.error !== null || outcome.applied === undefined || !deps.afterSync) {
|
|
132
|
+
return outcome.error;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await deps.afterSync({ tenantId, event, ...outcome.applied });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return {
|
|
140
|
+
code: "tier_sync_after_hook_failed",
|
|
141
|
+
message: error instanceof Error ? error.message : "afterSync threw a non-Error value",
|
|
114
142
|
};
|
|
115
143
|
}
|
|
116
144
|
}
|
|
@@ -118,8 +146,8 @@ export function createSubscriptionTierSync(deps) {
|
|
|
118
146
|
function createWebhookRoute(options = {}) {
|
|
119
147
|
return createSubscriptionWebhookRoute({
|
|
120
148
|
path: options.path ?? SUBSCRIPTION_WEBHOOK_PATH,
|
|
121
|
-
afterDispatch: async (dispatched, tenantId, routeDeps) => {
|
|
122
|
-
const syncError = await
|
|
149
|
+
afterDispatch: async (dispatched, tenantId, routeDeps, event) => {
|
|
150
|
+
const syncError = await syncAndNotify(tenantId, event, routeDeps);
|
|
123
151
|
if (syncError) {
|
|
124
152
|
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
125
153
|
console.warn(`[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`);
|
|
@@ -140,5 +168,5 @@ export function createSubscriptionTierSync(deps) {
|
|
|
140
168
|
},
|
|
141
169
|
});
|
|
142
170
|
}
|
|
143
|
-
return { createWebhookRoute, syncTierFromSubscription };
|
|
171
|
+
return { createWebhookRoute, syncTierFromSubscription, syncAndNotify };
|
|
144
172
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PaymentEvent, SubscriptionEvent } from "./types.ts";
|
|
1
2
|
export type SubscriptionWebhookRouteOptions = {
|
|
2
3
|
/** Route path — MUST carry the `:providerName` path-param. Default
|
|
3
4
|
* "/api/subscription/webhook/:providerName". */
|
|
@@ -7,7 +8,7 @@ export type SubscriptionWebhookRouteOptions = {
|
|
|
7
8
|
* duplicating the provider-resolve/payload-mapping logic above. Returning
|
|
8
9
|
* a failed WriteResult turns the response into the same 500 a dispatch
|
|
9
10
|
* failure would produce. */
|
|
10
|
-
readonly afterDispatch?: (dispatched: import("@cosmicdrift/kumiko-framework/engine").WriteResult, tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId, deps: import("@cosmicdrift/kumiko-framework/api").SignatureExtraRouteDeps) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
|
|
11
|
+
readonly afterDispatch?: (dispatched: import("@cosmicdrift/kumiko-framework/engine").WriteResult, tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId, deps: import("@cosmicdrift/kumiko-framework/api").SignatureExtraRouteDeps, event: SubscriptionEvent | PaymentEvent) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
13
14
|
* Builds the `entry:"signature"` extraRoute definition. Mount via
|
|
@@ -68,7 +68,7 @@ export function createSubscriptionWebhookRoute(options = {}) {
|
|
|
68
68
|
},
|
|
69
69
|
});
|
|
70
70
|
return respondFromDispatch(c, dispatched.isSuccess && options.afterDispatch
|
|
71
|
-
? await options.afterDispatch(dispatched, tenantId, deps)
|
|
71
|
+
? await options.afterDispatch(dispatched, tenantId, deps, parsed)
|
|
72
72
|
: dispatched, "subscription_payment_webhook_processing_failed", "Internal error processing payment event");
|
|
73
73
|
}
|
|
74
74
|
const dispatched = await deps.dispatchSystemWrite({
|
|
@@ -88,7 +88,7 @@ export function createSubscriptionWebhookRoute(options = {}) {
|
|
|
88
88
|
},
|
|
89
89
|
});
|
|
90
90
|
return respondFromDispatch(c, dispatched.isSuccess && options.afterDispatch
|
|
91
|
-
? await options.afterDispatch(dispatched, tenantId, deps)
|
|
91
|
+
? await options.afterDispatch(dispatched, tenantId, deps, parsed)
|
|
92
92
|
: dispatched, "subscription_webhook_processing_failed", "Internal error processing subscription event");
|
|
93
93
|
},
|
|
94
94
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
/** S3-compatible endpoint URL: https, or http on a loopback host
|
|
2
|
+
/** S3-compatible endpoint URL: https, or http on a loopback host; any http host outside `NODE_ENV=production`. */
|
|
3
3
|
export declare const s3EndpointSchema: z.ZodURL;
|
|
4
4
|
/** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
|
|
5
5
|
* `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
3
|
-
// The bucket holds tenant files, so a remote endpoint must be
|
|
4
|
-
//
|
|
5
|
-
|
|
3
|
+
// The bucket holds tenant files, so in production a remote endpoint must be
|
|
4
|
+
// https. Outside production plain http stays allowed, also for compose-network
|
|
5
|
+
// hostnames (minio, rustfs) that are not loopback.
|
|
6
|
+
function isAcceptableEndpoint(value) {
|
|
6
7
|
if (!URL.canParse(value))
|
|
7
8
|
return false;
|
|
8
9
|
const url = new URL(value);
|
|
9
|
-
|
|
10
|
+
if (url.protocol === "https:")
|
|
11
|
+
return true;
|
|
12
|
+
return process.env["NODE_ENV"] !== "production" || LOOPBACK_HOSTNAMES.has(url.hostname);
|
|
10
13
|
}
|
|
11
|
-
/** S3-compatible endpoint URL: https, or http on a loopback host
|
|
14
|
+
/** S3-compatible endpoint URL: https, or http on a loopback host; any http host outside `NODE_ENV=production`. */
|
|
12
15
|
export const s3EndpointSchema = z
|
|
13
16
|
.url({ protocol: /^https?$/ })
|
|
14
|
-
.refine(
|
|
17
|
+
.refine(isAcceptableEndpoint, "must use https unless the host is loopback");
|
|
15
18
|
/** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
|
|
16
19
|
* `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
|
|
17
20
|
* Every field is optional: an app that mounts the env provider next to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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>",
|
|
@@ -536,12 +536,12 @@
|
|
|
536
536
|
}
|
|
537
537
|
},
|
|
538
538
|
"dependencies": {
|
|
539
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
540
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
541
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
542
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
543
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
544
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
539
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.371.0",
|
|
540
|
+
"@cosmicdrift/kumiko-framework": "0.371.0",
|
|
541
|
+
"@cosmicdrift/kumiko-headless": "0.371.0",
|
|
542
|
+
"@cosmicdrift/kumiko-renderer": "0.371.0",
|
|
543
|
+
"@cosmicdrift/kumiko-renderer-web": "0.371.0",
|
|
544
|
+
"@cosmicdrift/kumiko-types": "0.371.0",
|
|
545
545
|
"@mollie/api-client": "^4.5.0",
|
|
546
546
|
"@node-rs/argon2": "^2.0.2",
|
|
547
547
|
"@tiptap/core": "3.31.3",
|
|
@@ -1102,12 +1102,12 @@
|
|
|
1102
1102
|
],
|
|
1103
1103
|
"devDependencies": {
|
|
1104
1104
|
"@testing-library/user-event": "^14.6.1",
|
|
1105
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1106
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1107
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
1105
|
+
"@cosmicdrift/kumiko-locale-de": "0.371.0",
|
|
1106
|
+
"@cosmicdrift/kumiko-locale-es": "0.371.0",
|
|
1107
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.371.0",
|
|
1108
1108
|
"jsqr": "^1.4.0",
|
|
1109
1109
|
"unpdf": "^1.8.1",
|
|
1110
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1110
|
+
"@cosmicdrift/kumiko-testing": "0.371.0"
|
|
1111
1111
|
},
|
|
1112
1112
|
"peerDependencies": {
|
|
1113
1113
|
"@cosmicdrift/kumiko-testing": ">=0.366.0 <1.0.0"
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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",
|