@substrat-run/adapter-cloudflare 0.162.0 → 0.163.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/control-plane-do.d.ts +65 -10
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +169 -30
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +69 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +195 -84
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +36 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +101 -20
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/control-plane-do.js
CHANGED
|
@@ -4,7 +4,7 @@ import { replyOf } from './do-reply.js';
|
|
|
4
4
|
import { switchSqlOver } from './scope-do.js';
|
|
5
5
|
import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
|
|
6
6
|
import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
|
|
7
|
-
import { opsFailureReferenceWhere } from '@substrat-run/kernel';
|
|
7
|
+
import { autoAdmittedProdRefusal, listedPromoteRefusal, opsFailureReferenceWhere } from '@substrat-run/kernel';
|
|
8
8
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
9
9
|
import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
10
10
|
/**
|
|
@@ -818,6 +818,9 @@ export const REPLIED_METHODS = [
|
|
|
818
818
|
'admitVersion',
|
|
819
819
|
'rejectVersion',
|
|
820
820
|
'setVerticalServing',
|
|
821
|
+
// #2565: refuses a listed vertical's prod with forbidden (`unlistedOnly`) or conflict (an
|
|
822
|
+
// auto-admitted version). A DO from before lists it not, so the current coordinator fails closed.
|
|
823
|
+
'promoteChannel',
|
|
821
824
|
// #2526: the role, grant and org writes at a tenant node, each with its admin row in its unit. A
|
|
822
825
|
// DO from before lists none of them, so the current coordinator fails closed against it. That is
|
|
823
826
|
// why the org write is `insertOrg` and not `createOrg` with a trailing row: an older DO lists
|
|
@@ -829,6 +832,16 @@ export const REPLIED_METHODS = [
|
|
|
829
832
|
'writeTenantSystemGrant',
|
|
830
833
|
'grantConnectionInDirectory',
|
|
831
834
|
'insertOrg',
|
|
835
|
+
// #2526: the hostname and entitlement writes, each reading its row and judging its no-op in the
|
|
836
|
+
// unit that writes it and its admin row. A DO from before lists none of them, so the current
|
|
837
|
+
// coordinator fails closed against it; a coordinator from before calls them directly, without
|
|
838
|
+
// the trailing row, and gets the bare write it always did.
|
|
839
|
+
'bindHostnameInDirectory',
|
|
840
|
+
'setHostnameStatus',
|
|
841
|
+
'setHostnameIssuance',
|
|
842
|
+
'deleteHostname',
|
|
843
|
+
'grantEntitlement',
|
|
844
|
+
'revokeEntitlement',
|
|
832
845
|
];
|
|
833
846
|
export class ControlPlaneDO extends DurableObject {
|
|
834
847
|
sql;
|
|
@@ -1382,6 +1395,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1382
1395
|
if (status === 'reaped') {
|
|
1383
1396
|
throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
|
|
1384
1397
|
}
|
|
1398
|
+
if (before.status === 'reaped')
|
|
1399
|
+
throw substratError('conflict', `tenant ${tenantId} is reaped and cannot change status`);
|
|
1385
1400
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
1386
1401
|
// entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
|
|
1387
1402
|
// transitionScope uses for archived_at.
|
|
@@ -2205,7 +2220,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2205
2220
|
scope_hostnames: JSON.parse(scope_hostnames_json),
|
|
2206
2221
|
};
|
|
2207
2222
|
}
|
|
2208
|
-
/**
|
|
2223
|
+
/**
|
|
2224
|
+
* Demote any current canonical for this surface — exactly one may hold it. This and
|
|
2225
|
+
* `upsertHostname` are `bindHostnameInDirectory`'s steps; a coordinator from before #2526 still
|
|
2226
|
+
* calls them directly.
|
|
2227
|
+
*/
|
|
2209
2228
|
demoteCanonical(scopeId, surface) {
|
|
2210
2229
|
this.sql.exec('UPDATE hostnames SET canonical = 0 WHERE scope_id = ? AND surface = ?', scopeId, surface);
|
|
2211
2230
|
}
|
|
@@ -2218,8 +2237,62 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2218
2237
|
status, status_note, canonical, created_at, custom_hostname_id, validation_records)
|
|
2219
2238
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, NULL, NULL)`, h.hostname, h.tenantId, h.scopeId, h.verticalSlug, h.surface, h.region, h.canonical ? 1 : 0, h.createdAt);
|
|
2220
2239
|
}
|
|
2221
|
-
|
|
2222
|
-
|
|
2240
|
+
/**
|
|
2241
|
+
* #2526: `bindHostname` as one unit — the scope (in the tenant), the name's current holder and
|
|
2242
|
+
* whether it released the name (archived or reaped), the demotion of the surface's canonical,
|
|
2243
|
+
* the bind and its admin row, whose vertical is the scope's as read here. A name another live
|
|
2244
|
+
* scope holds is refused `conflict`, writing nothing; so is the demotion when the bind fails.
|
|
2245
|
+
*/
|
|
2246
|
+
bindHostnameInDirectory(h, audit) {
|
|
2247
|
+
this.ctx.storage.transactionSync(() => {
|
|
2248
|
+
const scope = this.getScopeRecord(h.tenantId, h.scopeId);
|
|
2249
|
+
if (!scope)
|
|
2250
|
+
throw substratError('not_found', `unknown scope ${h.scopeId} in tenant ${h.tenantId}`);
|
|
2251
|
+
const existing = this.readHostname(h.hostname);
|
|
2252
|
+
if (existing && existing.scope_id !== h.scopeId) {
|
|
2253
|
+
// A hostname routes to exactly one place; silently rebinding would move another
|
|
2254
|
+
// tenant's traffic. Exception: the holder is ARCHIVED or REAPED (a deleted app, storage
|
|
2255
|
+
// since wiped) — it has released the name, so the rebind reclaims it.
|
|
2256
|
+
const holder = this.getScopeRecord(existing.tenant_id, existing.scope_id);
|
|
2257
|
+
if (holder?.status !== 'archived' && holder?.status !== 'reaped') {
|
|
2258
|
+
throw substratError('conflict', `hostname '${h.hostname}' is already bound to another scope`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (h.canonical)
|
|
2262
|
+
this.demoteCanonical(h.scopeId, h.surface);
|
|
2263
|
+
this.upsertHostname({ ...h, verticalSlug: scope.vertical });
|
|
2264
|
+
this.recordAdmin({ ...audit, vertical: scope.vertical });
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
/**
|
|
2268
|
+
* With `audit` (#2526, through `reply`): the row is read here — refused `not_found` when the
|
|
2269
|
+
* name is not bound — and a status it already holds writes nothing and records nothing;
|
|
2270
|
+
* otherwise the update and its admin row, its target and `before` from that read, commit as
|
|
2271
|
+
* one unit. Answers whether it wrote. Without, as a coordinator from before #2526 calls it, the
|
|
2272
|
+
* bare update.
|
|
2273
|
+
*/
|
|
2274
|
+
setHostnameStatus(hostname, status, note, audit) {
|
|
2275
|
+
return this.auditedHostnameUpdate(hostname, (row) => row.status === status, () => this.sql.exec('UPDATE hostnames SET status = ?, status_note = ? WHERE hostname = ?', status, note, hostname).rowsWritten > 0, audit);
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* #2526: a hostname update with its admin row. Without `audit`, the bare `write`. With it, one
|
|
2279
|
+
* unit: the row read (refused `not_found` when the name is not bound), nothing written and
|
|
2280
|
+
* nothing recorded when `unchanged` judges it so, else the write and the row, whose target and
|
|
2281
|
+
* `before` status are the row as read.
|
|
2282
|
+
*/
|
|
2283
|
+
auditedHostnameUpdate(hostname, unchanged, write, audit) {
|
|
2284
|
+
if (!audit)
|
|
2285
|
+
return write();
|
|
2286
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2287
|
+
const row = this.readHostname(hostname);
|
|
2288
|
+
if (!row)
|
|
2289
|
+
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
2290
|
+
if (unchanged(row))
|
|
2291
|
+
return false; // idempotent, and a no-op is not audited
|
|
2292
|
+
write();
|
|
2293
|
+
this.recordAdmin({ ...audit, tenantId: row.tenant_id, scopeId: row.scope_id, before: { status: row.status } });
|
|
2294
|
+
return true;
|
|
2295
|
+
});
|
|
2223
2296
|
}
|
|
2224
2297
|
/**
|
|
2225
2298
|
* Record the result of a Cloudflare-for-SaaS issuance step (§4.7) — the status the
|
|
@@ -2228,25 +2301,42 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2228
2301
|
* `undefined`); the DNS records and status are refreshed each pass. This is the only
|
|
2229
2302
|
* writer that moves a hostname off `pending` into `verifying`/`active`/`failed` for a
|
|
2230
2303
|
* custom domain.
|
|
2304
|
+
*
|
|
2305
|
+
* With `audit` (#2526, through `reply`): the row is read here — refused `not_found` when the
|
|
2306
|
+
* name is not bound — and a poll that finds nothing changed (the same status, the same records,
|
|
2307
|
+
* the id already set) writes nothing and records nothing; otherwise the update and its admin
|
|
2308
|
+
* row, its target and `before` from that read, commit as one unit. Answers whether it wrote.
|
|
2309
|
+
* Without, as a coordinator from before #2526 calls it, the bare update.
|
|
2231
2310
|
*/
|
|
2232
|
-
setHostnameIssuance(hostname, fields) {
|
|
2233
|
-
|
|
2234
|
-
|
|
2311
|
+
setHostnameIssuance(hostname, fields, audit) {
|
|
2312
|
+
// A poll that changes nothing is not an event, so the reconcile sweep does not flood the
|
|
2313
|
+
// admin log with no-op rows every interval. No records are stored as NULL.
|
|
2314
|
+
const unchanged = (row) => row.status === fields.status &&
|
|
2315
|
+
(row.validation_records ?? null) === fields.validationRecords &&
|
|
2316
|
+
(fields.customHostnameId === undefined || fields.customHostnameId === row.custom_hostname_id);
|
|
2317
|
+
return this.auditedHostnameUpdate(hostname, unchanged, () => (fields.customHostnameId !== undefined
|
|
2318
|
+
? this.sql.exec(`UPDATE hostnames
|
|
2235
2319
|
SET status = ?, status_note = ?, custom_hostname_id = ?, validation_records = ?
|
|
2236
|
-
WHERE hostname = ?`, fields.status, fields.note, fields.customHostnameId, fields.validationRecords, hostname)
|
|
2237
|
-
|
|
2238
|
-
else {
|
|
2239
|
-
this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname);
|
|
2240
|
-
}
|
|
2320
|
+
WHERE hostname = ?`, fields.status, fields.note, fields.customHostnameId, fields.validationRecords, hostname)
|
|
2321
|
+
: this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname)).rowsWritten > 0, audit);
|
|
2241
2322
|
}
|
|
2242
2323
|
/**
|
|
2243
2324
|
* Delete a binding and answer the row it removed, or null. With `owner`, only while the row
|
|
2244
2325
|
* still names that tenant and scope (#2285): the delete decides it, not an earlier read.
|
|
2326
|
+
* With `audit` (#2526, through `reply`), the admin row, its target and `before` from the row
|
|
2327
|
+
* removed, commits with the delete; a delete that removed nothing records nothing.
|
|
2245
2328
|
*/
|
|
2246
|
-
deleteHostname(hostname, owner) {
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2329
|
+
deleteHostname(hostname, owner, audit) {
|
|
2330
|
+
let removed = null;
|
|
2331
|
+
this.writeAndAudit(() => {
|
|
2332
|
+
const rows = this.sql.exec(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
|
|
2333
|
+
RETURNING tenant_id, scope_id, status`, hostname, ...(owner ? [owner.tenantId, owner.scopeId] : [])).toArray();
|
|
2334
|
+
removed = rows[0] ?? null;
|
|
2335
|
+
return removed !== null;
|
|
2336
|
+
}, audit && (() => ({
|
|
2337
|
+
...audit, tenantId: removed.tenant_id, scopeId: removed.scope_id, before: { hostname, status: removed.status },
|
|
2338
|
+
})));
|
|
2339
|
+
return removed;
|
|
2250
2340
|
}
|
|
2251
2341
|
listHostnames(filter) {
|
|
2252
2342
|
const where = [];
|
|
@@ -2310,10 +2400,8 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2310
2400
|
.exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
|
|
2311
2401
|
WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
|
|
2312
2402
|
.toArray()[0];
|
|
2313
|
-
if (prod?.admission_note === AUTO_ADMISSION_NOTE)
|
|
2314
|
-
throw
|
|
2315
|
-
`a staff admit must vouch for it before listing`);
|
|
2316
|
-
}
|
|
2403
|
+
if (prod?.admission_note === AUTO_ADMISSION_NOTE)
|
|
2404
|
+
throw autoAdmittedProdRefusal(slug, prod.id);
|
|
2317
2405
|
}
|
|
2318
2406
|
return { listed: !!r.listed };
|
|
2319
2407
|
}, audit);
|
|
@@ -2781,15 +2869,23 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2781
2869
|
* Remove a reaped fork's directory presence: hostname bindings + the scope row
|
|
2782
2870
|
* (preview-and-snapshots.md §9). The coordinator's deleteSnapshot calls this AFTER
|
|
2783
2871
|
* wiping the scope DO's storage — the fork-only refusal lives there, not here.
|
|
2872
|
+
* #2555: with `onlyWhile`, only a scope still in that status is removed, judged in the same unit,
|
|
2873
|
+
* so a scope another call activated in between keeps its row; answers whether it was removed.
|
|
2784
2874
|
*/
|
|
2785
|
-
deleteScopeDirectory(scopeId) {
|
|
2786
|
-
this.ctx.storage.transactionSync(() => {
|
|
2875
|
+
deleteScopeDirectory(scopeId, onlyWhile) {
|
|
2876
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2877
|
+
if (onlyWhile) {
|
|
2878
|
+
const row = this.sql.exec('SELECT status FROM scopes WHERE scope_id = ?', scopeId).toArray()[0];
|
|
2879
|
+
if (row?.status !== onlyWhile)
|
|
2880
|
+
return false;
|
|
2881
|
+
}
|
|
2787
2882
|
this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
|
|
2788
2883
|
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2789
2884
|
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
2790
2885
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
2791
2886
|
forgetScopeStorage(doRedactionSql(this.sql), scopeId);
|
|
2792
2887
|
this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
|
|
2888
|
+
return true;
|
|
2793
2889
|
});
|
|
2794
2890
|
}
|
|
2795
2891
|
/**
|
|
@@ -2814,12 +2910,28 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2814
2910
|
* channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
|
|
2815
2911
|
* runs between the read and the write: a promote judged its gates against `from` across awaits,
|
|
2816
2912
|
* and the one that lost the race is told so (`false`) and writes nothing, `audit` included.
|
|
2913
|
+
*
|
|
2914
|
+
* #2565: the vertical's `listed` is read in the same unit — refusing a listed vertical when
|
|
2915
|
+
* `unlistedOnly`, and an auto-admitted version as a listed vertical's new prod. It replaces
|
|
2916
|
+
* `moveChannel`, which judged neither: a new name, so a DO from before refuses the call and a
|
|
2917
|
+
* coordinator from before finds no `moveChannel` to call. Both fail closed.
|
|
2817
2918
|
*/
|
|
2818
|
-
|
|
2919
|
+
promoteChannel(from, history, unlistedOnly, audit) {
|
|
2819
2920
|
return this.writeAndAudit(() => {
|
|
2820
2921
|
const now = this.readChannel(history.vertical_slug, history.channel);
|
|
2821
2922
|
if ((now?.version_id ?? null) !== from)
|
|
2822
2923
|
return false;
|
|
2924
|
+
const vertical = this.readVertical(history.vertical_slug);
|
|
2925
|
+
const listed = vertical?.listed;
|
|
2926
|
+
if (listed && unlistedOnly)
|
|
2927
|
+
throw listedPromoteRefusal();
|
|
2928
|
+
// A re-promote of what prod already names, and what serves (if anything does), moves
|
|
2929
|
+
// nothing, so it is not judged again. One whose serve would change what runs is.
|
|
2930
|
+
const serving = vertical?.serving_ref && vertical.serving_version_id ? vertical.serving_version_id : history.version_id;
|
|
2931
|
+
if (listed && history.channel === 'prod' && (from !== history.version_id || serving !== history.version_id) &&
|
|
2932
|
+
this.versionAdmission(history.version_id).admission_note === AUTO_ADMISSION_NOTE) {
|
|
2933
|
+
throw autoAdmittedProdRefusal(history.vertical_slug, history.version_id);
|
|
2934
|
+
}
|
|
2823
2935
|
this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
|
|
2824
2936
|
VALUES (?, ?, ?, ?)
|
|
2825
2937
|
ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, history.vertical_slug, history.channel, history.version_id, history.at, history.version_id, history.at);
|
|
@@ -3031,8 +3143,24 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3031
3143
|
/**
|
|
3032
3144
|
* Upsert; returns before/after for the host's audit row, `changed: false` when
|
|
3033
3145
|
* the merged result equals what the row already carried (not audited upstream).
|
|
3146
|
+
* With `audit` (#2526, through `reply`), an effective change commits with its admin row,
|
|
3147
|
+
* `before` and `after` the grant as read and written here; an unchanged grant records none.
|
|
3034
3148
|
*/
|
|
3035
|
-
grantEntitlement(tenantId, key, input, actor) {
|
|
3149
|
+
grantEntitlement(tenantId, key, input, actor, audit) {
|
|
3150
|
+
let result;
|
|
3151
|
+
this.writeAndAudit(() => {
|
|
3152
|
+
result = this.grantEntitlementIn(tenantId, key, input, actor);
|
|
3153
|
+
return result.changed;
|
|
3154
|
+
}, audit && (() => ({
|
|
3155
|
+
...audit,
|
|
3156
|
+
before: result.before
|
|
3157
|
+
? { entitlementKey: key, expiresAt: result.before.expires_at, quota: result.before.quota, plan: result.before.plan }
|
|
3158
|
+
: null,
|
|
3159
|
+
after: { entitlementKey: key, expiresAt: result.after.expires_at, quota: result.after.quota, plan: result.after.plan },
|
|
3160
|
+
})));
|
|
3161
|
+
return result;
|
|
3162
|
+
}
|
|
3163
|
+
grantEntitlementIn(tenantId, key, input, actor) {
|
|
3036
3164
|
const existing = this.readEntitlement(tenantId, key);
|
|
3037
3165
|
const next = {
|
|
3038
3166
|
expiresAt: input.expiresAt !== undefined ? input.expiresAt : (existing?.expires_at ?? null),
|
|
@@ -3070,12 +3198,23 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
3070
3198
|
},
|
|
3071
3199
|
};
|
|
3072
3200
|
}
|
|
3073
|
-
/**
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3201
|
+
/**
|
|
3202
|
+
* DELETE; returns the removed row (the host's audit `before`), null if none. With `audit`
|
|
3203
|
+
* (#2526, through `reply`), the admin row, `before` the row removed, commits with the delete;
|
|
3204
|
+
* a revoke of nothing held records nothing.
|
|
3205
|
+
*/
|
|
3206
|
+
revokeEntitlement(tenantId, key, audit) {
|
|
3207
|
+
let existing = null;
|
|
3208
|
+
this.writeAndAudit(() => {
|
|
3209
|
+
existing = this.readEntitlement(tenantId, key);
|
|
3210
|
+
if (!existing)
|
|
3211
|
+
return false;
|
|
3212
|
+
this.sql.exec('DELETE FROM _substrat_entitlements WHERE tenant_id = ? AND entitlement_key = ?', tenantId, key);
|
|
3213
|
+
return true;
|
|
3214
|
+
}, audit && (() => ({
|
|
3215
|
+
...audit,
|
|
3216
|
+
before: { entitlementKey: key, expiresAt: existing.expires_at, quota: existing.quota, plan: existing.plan },
|
|
3217
|
+
})));
|
|
3079
3218
|
return existing;
|
|
3080
3219
|
}
|
|
3081
3220
|
tenantHoldsEntitlement(tenantId, key) {
|