@substrat-run/adapter-cloudflare 0.89.0 → 0.90.1
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 +8 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +25 -1
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +15 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +97 -5
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +117 -17
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
package/dist/host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
|
|
2
2
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
3
|
-
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, } from '@substrat-run/kernel';
|
|
3
|
+
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, } from '@substrat-run/kernel';
|
|
4
4
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
5
5
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
6
6
|
/** DO row → contract shape. Never reads the secrets table — that is the split. */
|
|
@@ -28,6 +28,7 @@ function rowToPlatformRequest(r) {
|
|
|
28
28
|
kind: r.kind,
|
|
29
29
|
payload: JSON.parse(r.payload),
|
|
30
30
|
requestedBy: JSON.parse(r.requested_by),
|
|
31
|
+
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
31
32
|
status: r.status,
|
|
32
33
|
attempts: r.attempts,
|
|
33
34
|
lastError: r.last_error,
|
|
@@ -1025,6 +1026,33 @@ export class CloudflareScopeHost {
|
|
|
1025
1026
|
await this.migrateAndRecord(scopeId);
|
|
1026
1027
|
return this.buildStub(conn.tenant_id, scopeId, undefined, connectionId);
|
|
1027
1028
|
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Read a session and hold it to its own rules — the lookup behind both the door
|
|
1031
|
+
* and every invoke through it. Kept in one place so the two can never disagree
|
|
1032
|
+
* about what "still usable" means.
|
|
1033
|
+
*/
|
|
1034
|
+
async resolveImpersonation(session, tenantId, scopeId) {
|
|
1035
|
+
const row = await this.cp.readImpersonation(String(session));
|
|
1036
|
+
if (!row)
|
|
1037
|
+
throw new ImpersonationRefused(`unknown impersonation session: ${session}`);
|
|
1038
|
+
const record = mapImpersonationRow(row);
|
|
1039
|
+
assertSessionUsable(record, new Date().toISOString(), { tenantId, scopeId });
|
|
1040
|
+
return record;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* The impersonation door (K-42, #868) — mirror of `getConnectorScope` and
|
|
1044
|
+
* `getSystemScope`. The session is the authority and it is read from the
|
|
1045
|
+
* directory, never described by the caller: a stub can only be minted by naming
|
|
1046
|
+
* a session somebody opened and the admin log already recorded.
|
|
1047
|
+
*/
|
|
1048
|
+
async getImpersonatedScope(session, tenantId, scopeId, options) {
|
|
1049
|
+
const record = await this.resolveImpersonation(session, tenantId, scopeId);
|
|
1050
|
+
// The same lifecycle gate the principal door applies: a suspended tenant
|
|
1051
|
+
// refuses a support session exactly as it refuses a user.
|
|
1052
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1053
|
+
await this.migrateAndRecord(scopeId);
|
|
1054
|
+
return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
|
|
1055
|
+
}
|
|
1028
1056
|
/**
|
|
1029
1057
|
* A scope stub whose authority is a MODULE on a timer (#383) — the scheduler's
|
|
1030
1058
|
* door, mirror of `getConnectorScope`. The module must be registered on this host
|
|
@@ -1095,8 +1123,16 @@ export class CloudflareScopeHost {
|
|
|
1095
1123
|
}
|
|
1096
1124
|
return report;
|
|
1097
1125
|
}
|
|
1098
|
-
/** The stub body, shared by the principal, connection, and
|
|
1099
|
-
buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options
|
|
1126
|
+
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
1127
|
+
buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options,
|
|
1128
|
+
/**
|
|
1129
|
+
* K-42: the session this stub acts under. Re-read from the directory on
|
|
1130
|
+
* EVERY invoke rather than captured here — a stub is a capability and
|
|
1131
|
+
* nothing takes it away, so a session checked only at the door would be a
|
|
1132
|
+
* time box that never runs out for the one caller holding it, and
|
|
1133
|
+
* `endImpersonation` would stop nothing.
|
|
1134
|
+
*/
|
|
1135
|
+
sessionId) {
|
|
1100
1136
|
const stub = this.scopeStub(scopeId);
|
|
1101
1137
|
const cp = this.cp;
|
|
1102
1138
|
const operationEntitlement = this.operationEntitlement;
|
|
@@ -1125,7 +1161,9 @@ export class CloudflareScopeHost {
|
|
|
1125
1161
|
expired: r.expires_at !== null && r.expires_at <= now,
|
|
1126
1162
|
})))));
|
|
1127
1163
|
}
|
|
1128
|
-
|
|
1164
|
+
// K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
|
|
1165
|
+
const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
|
|
1166
|
+
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session);
|
|
1129
1167
|
// The operation failed and the DO handed the error back as DATA — so it still
|
|
1130
1168
|
// has its code and extensions, which a throw across this boundary would have
|
|
1131
1169
|
// stripped down to a message (#113 §3). Rethrown here, where the caller expects
|
|
@@ -1154,7 +1192,24 @@ export class CloudflareScopeHost {
|
|
|
1154
1192
|
throw substratError('unavailable', `${operation} ran on a scope host that did not honour Idempotency-Key — the ` +
|
|
1155
1193
|
'operation may have executed a second time. Retry once the scope has been migrated');
|
|
1156
1194
|
}
|
|
1157
|
-
|
|
1195
|
+
// K-42, and the same shape as the two skew refusals above with a sharper
|
|
1196
|
+
// reason: a DO too old to know about sessions ran this as the impersonated
|
|
1197
|
+
// principal with nothing stamped and no read-only bound. The write has
|
|
1198
|
+
// committed; what this refuses is the SUCCESS, because a support session
|
|
1199
|
+
// believed to be recorded and bounded is worse than no support session.
|
|
1200
|
+
if (session !== undefined && envelope.impersonation?.honoured !== true) {
|
|
1201
|
+
throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
|
|
1202
|
+
'operation may have run unrecorded and unbounded. Retry once the scope has ' +
|
|
1203
|
+
'been migrated');
|
|
1204
|
+
}
|
|
1205
|
+
// K-42, the coordinator half of the pure adapter's post-commit guard: a
|
|
1206
|
+
// read-only session's transaction was rolled back, so it added nothing to
|
|
1207
|
+
// this scope's outbox — and draining anyway would run executors and
|
|
1208
|
+
// connectors as a side effect of a session that may not have side effects.
|
|
1209
|
+
// Whatever the outbox already held is the drain sweep's own backstop.
|
|
1210
|
+
const drained = session?.mode === 'read-only'
|
|
1211
|
+
? { attempted: 0, delivered: 0, retrying: 0, deadLettered: 0, routedToPlatform: 0 }
|
|
1212
|
+
: await this.drainExecutors(tenantId, scopeId);
|
|
1158
1213
|
// #458: the operation committed having enqueued platform intents — tell the
|
|
1159
1214
|
// caller's harness so it can flag the response for the router kick (#381).
|
|
1160
1215
|
// Routed connector deliveries (#574 phase 3) count too: the inline drain just
|
|
@@ -2230,6 +2285,43 @@ export class CloudflareScopeHost {
|
|
|
2230
2285
|
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted);
|
|
2231
2286
|
return receipt;
|
|
2232
2287
|
},
|
|
2288
|
+
// -- impersonation (K-42, #868) ----------------------------------------
|
|
2289
|
+
beginImpersonation: async (actor, input) => {
|
|
2290
|
+
// Fail closed on the scope before minting anything: a session naming a
|
|
2291
|
+
// scope that is not there is a credential for nothing, and issuing one
|
|
2292
|
+
// anyway leaves the admin log recording an access that could not happen.
|
|
2293
|
+
await this.assertScope(input.tenantId, input.scopeId);
|
|
2294
|
+
const session = newImpersonationSession(ulid(), actor, input, new Date().toISOString());
|
|
2295
|
+
await this.cp.writeImpersonation(impersonationRowValues(session));
|
|
2296
|
+
// BEFORE the session is usable, so the log entry precedes every row it
|
|
2297
|
+
// will ever touch. The reason rides in `after`: it is what a review reads
|
|
2298
|
+
// first, and a log saying a session opened but not why is half a record.
|
|
2299
|
+
await this.recordAdmin(actor, 'beginImpersonation', { tenantId: session.tenantId, scopeId: session.scopeId }, null, session);
|
|
2300
|
+
return session;
|
|
2301
|
+
},
|
|
2302
|
+
endImpersonation: async (actor, id) => {
|
|
2303
|
+
const row = await this.cp.readImpersonation(String(id));
|
|
2304
|
+
if (!row)
|
|
2305
|
+
throw new ImpersonationRefused(`unknown impersonation session: ${id}`);
|
|
2306
|
+
const before = mapImpersonationRow(row);
|
|
2307
|
+
// Idempotent: "stop that session" must not fail because somebody else
|
|
2308
|
+
// already stopped it.
|
|
2309
|
+
if (before.endedAt !== null)
|
|
2310
|
+
return before;
|
|
2311
|
+
await this.cp.endImpersonation(String(id), new Date().toISOString());
|
|
2312
|
+
const after = mapImpersonationRow((await this.cp.readImpersonation(String(id))));
|
|
2313
|
+
await this.recordAdmin(actor, 'endImpersonation', { tenantId: after.tenantId, scopeId: after.scopeId }, before, after);
|
|
2314
|
+
return after;
|
|
2315
|
+
},
|
|
2316
|
+
listImpersonations: async (actor, filter) => {
|
|
2317
|
+
const rows = await this.cp.listImpersonations(filter ?? {}, new Date().toISOString());
|
|
2318
|
+
const sessions = rows.map(mapImpersonationRow);
|
|
2319
|
+
// A staff READ, logged like every other one (K-24) — `result_count`
|
|
2320
|
+
// included, which is what separates "checked one session" from
|
|
2321
|
+
// "enumerated every support session on the platform".
|
|
2322
|
+
await this.recordAccess(actor, 'listImpersonations', { tenantId: (filter?.tenantId ?? null) }, filter ?? null, sessions.length);
|
|
2323
|
+
return sessions;
|
|
2324
|
+
},
|
|
2233
2325
|
grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
|
|
2234
2326
|
const input = entitlementGrantInput.parse(plan ?? {});
|
|
2235
2327
|
const result = await this.cp.grantEntitlement(tenantId, entitlementKey, input, actor);
|