@substrat-run/control-plane-api 0.158.0 → 0.160.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/api.d.ts +25 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +570 -55
- package/dist/api.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/browser.js.map +1 -1
- package/dist/cf-observability.d.ts +30 -5
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +277 -92
- package/dist/cf-observability.js.map +1 -1
- package/dist/custom-hostnames.d.ts +30 -0
- package/dist/custom-hostnames.d.ts.map +1 -1
- package/dist/custom-hostnames.js +25 -2
- package/dist/custom-hostnames.js.map +1 -1
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +26 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +6 -13
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +103 -40
- package/dist/platform-drain.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +2 -2
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +5 -5
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-wire.d.ts +51 -0
- package/dist/platform-wire.d.ts.map +1 -0
- package/dist/platform-wire.js +163 -0
- package/dist/platform-wire.js.map +1 -0
- package/dist/scope-copy-backfill.d.ts.map +1 -1
- package/dist/scope-copy-backfill.js +22 -9
- package/dist/scope-copy-backfill.js.map +1 -1
- package/dist/staff-client.d.ts +38 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +24 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +59 -3
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +144 -6
- package/dist/vertical-client.js.map +1 -1
- package/dist/wire-upload.d.ts +74 -0
- package/dist/wire-upload.d.ts.map +1 -0
- package/dist/wire-upload.js +114 -0
- package/dist/wire-upload.js.map +1 -0
- package/package.json +10 -10
package/dist/api.js
CHANGED
|
@@ -2,14 +2,15 @@ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
|
2
2
|
import { connectionBindingOptions } from './connection-binding-options.js';
|
|
3
3
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
-
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
|
|
5
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
|
|
6
|
+
import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
|
|
6
7
|
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
7
8
|
import { attributeFailure } from './failure-attribution.js';
|
|
8
9
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
9
|
-
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
10
|
+
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isExportBreakUnjudged, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, REQUEST_ROLLUP_LIMIT_DEFAULT, REQUEST_ROLLUP_LIMIT_MAX, METER_DAYS_LIMIT_DEFAULT, METER_DAYS_LIMIT_MAX, releaseRolesAskScopes, releaseRoleSetRefusal, withheldOpsFailure, } from '@substrat-run/kernel';
|
|
10
11
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
11
12
|
import { mintTenantToken } from './tenant-token.js';
|
|
12
|
-
import { beginTransfer, connectionGrantsForScope, transferScope } from './vertical-client.js';
|
|
13
|
+
import { beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
|
|
13
14
|
import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
|
|
14
15
|
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
15
16
|
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
@@ -61,11 +62,12 @@ function refuseStatus(c, status, detail, carried) {
|
|
|
61
62
|
}
|
|
62
63
|
import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
|
|
63
64
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
64
|
-
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
|
|
65
|
+
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS, LOGS_INCOMPLETE_HEADER } from './observability.js';
|
|
65
66
|
import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, scriptFamiliesOfScopes } from './tenant-request-query.js';
|
|
66
67
|
import { namespacesForScript } from './do-namespaces.js';
|
|
67
68
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
68
69
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
70
|
+
import { wireUploadFor } from './wire-upload.js';
|
|
69
71
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
70
72
|
import { queryLakeEvents } from './lake-gateway.js';
|
|
71
73
|
import { LakeQueryError } from './r2-sql-engine.js';
|
|
@@ -166,6 +168,46 @@ function withholdIfPredatesRule(input, answer) {
|
|
|
166
168
|
truncated: false,
|
|
167
169
|
};
|
|
168
170
|
}
|
|
171
|
+
/** #2446: the most scopes one promote reads the role state of (`roleStateScopeCap`). */
|
|
172
|
+
export const ROLE_STATE_SCOPE_CAP = 500;
|
|
173
|
+
/** #2446: how many of those reads are in flight at once. */
|
|
174
|
+
const ROLE_STATE_CONCURRENCY = 8;
|
|
175
|
+
/** #2446: what a promote answers about the scopes it could not ask for their roles. */
|
|
176
|
+
export const ROLE_STATE_UNREAD_NOTE = "their deployment predates the role-state read, so this version's roles were not judged against them before the " +
|
|
177
|
+
'promote; their reconcile still refuses a role set it cannot define';
|
|
178
|
+
/**
|
|
179
|
+
* #2446: the promote's refusal for a role-state judgement, or null when it passes, as `pin` may
|
|
180
|
+
* read it. A confined caller sees only its own tenant's scopes by id and every other tenant as a
|
|
181
|
+
* count, and never what a deployment answered (that is logged where it was read); staff see all of
|
|
182
|
+
* it. A refusal found (409) is answered ahead of a read that failed (502), since it stands either way.
|
|
183
|
+
*/
|
|
184
|
+
function roleStateAnswer(roles, pin) {
|
|
185
|
+
if (roles.refused.length > 0) {
|
|
186
|
+
const { visible, otherTenants } = narrowToCaller(roles.refused, pin);
|
|
187
|
+
const why = visible[0] ? `: ${visible[0].refusal}` : ', none of them yours';
|
|
188
|
+
return {
|
|
189
|
+
status: 409,
|
|
190
|
+
message: `promotion refused: this version's roles cannot be defined in ${roles.refused.length} of the scopes it would move, ` +
|
|
191
|
+
`so their reconcile would be refused there${why}. Nothing was promoted`,
|
|
192
|
+
extra: { roleRefusals: { affected: visible, ...(otherTenants ? { otherTenants } : {}) } },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (roles.failed.length > 0) {
|
|
196
|
+
const { visible, otherTenants } = narrowToCaller(roles.failed, pin);
|
|
197
|
+
return {
|
|
198
|
+
status: 502,
|
|
199
|
+
message: `the roles of ${roles.failed.length} of the scopes this version would move could not be read, and its roles are ` +
|
|
200
|
+
`judged against them before it moves them. Nothing was promoted; retry the promote`,
|
|
201
|
+
extra: {
|
|
202
|
+
roleStateUnreadable: {
|
|
203
|
+
affected: pin === null ? visible : visible.map(({ tenantId, scopeId }) => ({ tenantId, scopeId })),
|
|
204
|
+
...(otherTenants ? { otherTenants } : {}),
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
169
211
|
/**
|
|
170
212
|
* A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
|
|
171
213
|
* tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
|
|
@@ -190,9 +232,11 @@ const provisionScopeBody = z.object({
|
|
|
190
232
|
tenantId: tenantIdSchema,
|
|
191
233
|
scopeId: scopeIdSchema,
|
|
192
234
|
slug: z.string().optional(),
|
|
193
|
-
kind:
|
|
194
|
-
name:
|
|
195
|
-
|
|
235
|
+
kind: scopeText.optional(),
|
|
236
|
+
name: scopeText.optional(),
|
|
237
|
+
// The registry's grammar, as `resolveScopeRecord` holds it too: null or absent is "no vertical",
|
|
238
|
+
// and an empty string is refused here rather than stored.
|
|
239
|
+
vertical: verticalSlug.nullable().optional(),
|
|
196
240
|
storageShape: storageShape.optional(),
|
|
197
241
|
// The gate: only `global` is accepted until `eu`/`us` enforcement exists (K-32).
|
|
198
242
|
// A request naming a jurisdiction we cannot yet honour is refused at the Zod
|
|
@@ -614,9 +658,6 @@ const modelUsageSummaryQuery = z.object({
|
|
|
614
658
|
until: z.string().optional(),
|
|
615
659
|
});
|
|
616
660
|
export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
|
|
617
|
-
/** The upstream provider's trace handle, when a failure message carries one —
|
|
618
|
-
* Cloudflare's `internal error; reference = <id>` shape (#559). */
|
|
619
|
-
const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
|
|
620
661
|
const TENANT_ROUTES = [
|
|
621
662
|
{ method: 'GET', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
|
|
622
663
|
{ method: 'POST', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
|
|
@@ -1037,16 +1078,34 @@ export function createControlPlaneApi(options) {
|
|
|
1037
1078
|
const cause = c.get('failure');
|
|
1038
1079
|
const marked = typeof cause === 'object' && cause !== null && recordedCauses.has(cause);
|
|
1039
1080
|
if (!marked) {
|
|
1040
|
-
const { vertical, tenantId } = await failureSubject(c);
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1081
|
+
const { vertical, tenantId } = c.get('failureSubjectOf') ?? (await failureSubject(c));
|
|
1082
|
+
const scopeId = c.req.param('scopeId') ?? null;
|
|
1083
|
+
const base = { actor, operation: `${c.req.method} ${c.req.routePath}`, vertical, scopeId, status };
|
|
1084
|
+
const answered = await answeredMessage(c.res);
|
|
1085
|
+
const raw = cause !== undefined ? messageOfThrow(cause) : answered;
|
|
1086
|
+
if (tenantId !== null && raw !== answered) {
|
|
1087
|
+
// #2540: a row attributed to a tenant is read back by that tenant, so it says what this
|
|
1088
|
+
// request was answered, whoever made it. The throw's own text is unreviewed: it goes on a
|
|
1089
|
+
// row with no tenant, which only staff read, and to the log line, under the same reference.
|
|
1090
|
+
let reference = c.get('failureReference');
|
|
1091
|
+
if (!reference) {
|
|
1092
|
+
reference = ulid();
|
|
1093
|
+
console.error('control-plane.unhandled', { method: c.req.method, path: c.req.path, reference, tenantId, scopeId, detail: raw });
|
|
1094
|
+
}
|
|
1095
|
+
recordWithheld({ ...base, tenantId, reference }, raw, answered, cause);
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
recordFailure({
|
|
1099
|
+
...base,
|
|
1100
|
+
tenantId,
|
|
1101
|
+
message: raw,
|
|
1102
|
+
// The one the log line carries. `recordFailure` keeps an upstream handle in the text
|
|
1103
|
+
// off a tenant's row, on a staff row beside it, and takes it on a row with no tenant.
|
|
1104
|
+
reference: tenantId
|
|
1105
|
+
? (c.get('failureReference') ?? null)
|
|
1106
|
+
: (upstreamHandleIn(raw) ?? c.get('failureReference') ?? null),
|
|
1107
|
+
}, cause === undefined ? NO_CAUSE : cause);
|
|
1108
|
+
}
|
|
1050
1109
|
}
|
|
1051
1110
|
}
|
|
1052
1111
|
if (inflightRecords.size) {
|
|
@@ -1282,6 +1341,13 @@ export function createControlPlaneApi(options) {
|
|
|
1282
1341
|
// `undefined` (`throw undefined`, a bare rejection), and that throw deserves an
|
|
1283
1342
|
// `unknown` attribution, not a skipped one. Only true omission skips.
|
|
1284
1343
|
const NO_CAUSE = Symbol('no-cause');
|
|
1344
|
+
/**
|
|
1345
|
+
* #2540: the one kind of tenant row whose reference is an upstream trace handle. A failed upload
|
|
1346
|
+
* of a builder's own bundle answers the builder the upload's text verbatim, handle included, and
|
|
1347
|
+
* the CLI prints that handle for its lookup (#559), so the row keeps it as before. Every other
|
|
1348
|
+
* row filed under a tenant carries the platform's own reference. Named, never inferred from text.
|
|
1349
|
+
*/
|
|
1350
|
+
const UPSTREAM_REFERENCE_KEPT = new Set(['deploy.upload']);
|
|
1285
1351
|
// The throws a step already recorded, so the 5xx backstop below does not record them twice.
|
|
1286
1352
|
const recordedCauses = new WeakSet();
|
|
1287
1353
|
// The writes still in flight. A Worker may end once its response is sent, and a write nobody
|
|
@@ -1295,17 +1361,80 @@ export function createControlPlaneApi(options) {
|
|
|
1295
1361
|
// regex a message. A caller with no throw in hand leaves the columns null,
|
|
1296
1362
|
// which reads as "nobody classified this" rather than a guess nobody made.
|
|
1297
1363
|
const attributed = cause === NO_CAUSE ? undefined : attributeFailure(cause);
|
|
1364
|
+
// #2540: a row filed under a tenant is read back by it, so its reference is always the
|
|
1365
|
+
// platform's own. When its text carries an upstream trace handle (#559), that handle is kept
|
|
1366
|
+
// on a staff row beside it (`detail`, under the same reference), where staff's search finds it.
|
|
1367
|
+
// A row with no tenant is staff's: its text is written as a staff row's always is
|
|
1368
|
+
// (`withUpstreamReference`: any marker spelling neutralised, a genuine handle's marker first),
|
|
1369
|
+
// and it takes that handle as its reference, as before.
|
|
1370
|
+
const handle = upstreamHandleIn(entry.message);
|
|
1371
|
+
const staffMessage = entry.tenantId ? entry.message : withUpstreamReference(entry.message);
|
|
1372
|
+
const shaped = !entry.tenantId
|
|
1373
|
+
? { ...entry, message: staffMessage, reference: entry.reference ?? upstreamReferenceOf(staffMessage) }
|
|
1374
|
+
: UPSTREAM_REFERENCE_KEPT.has(entry.operation)
|
|
1375
|
+
? { ...entry, reference: entry.reference ?? handle }
|
|
1376
|
+
: entry.detail == null && handle
|
|
1377
|
+
? {
|
|
1378
|
+
...entry,
|
|
1379
|
+
reference: entry.reference && entry.reference !== handle ? entry.reference : ulid(),
|
|
1380
|
+
detail: withUpstreamReference(entry.message),
|
|
1381
|
+
}
|
|
1382
|
+
: { ...entry, reference: entry.reference ?? null };
|
|
1298
1383
|
const write = host.admin
|
|
1299
1384
|
.recordOpsFailure({
|
|
1300
|
-
...
|
|
1385
|
+
...shaped,
|
|
1301
1386
|
origin: entry.origin ?? attributed?.origin ?? null,
|
|
1302
1387
|
code: entry.code ?? attributed?.code ?? null,
|
|
1303
|
-
reference:
|
|
1388
|
+
reference: shaped.reference ?? null,
|
|
1304
1389
|
})
|
|
1305
1390
|
.catch(() => undefined)
|
|
1306
1391
|
.finally(() => inflightRecords.delete(write));
|
|
1307
1392
|
inflightRecords.add(write);
|
|
1308
1393
|
};
|
|
1394
|
+
/** #2540: whether this request's principal is staff. Anything else, or no principal yet, is confined. */
|
|
1395
|
+
const staffCaller = (c) => c.get('principal')?.kind === 'staff';
|
|
1396
|
+
/**
|
|
1397
|
+
* #2540: one failure whose text is not ours to hand a tenant. Filed under a tenant, it is one
|
|
1398
|
+
* write with a `detail`: the host puts what was answered on the tenant's row, which the tenant
|
|
1399
|
+
* reads back, and the text on a row with no tenant under the same reference, which only staff
|
|
1400
|
+
* read, and counts the failure once. With no tenant, the text is the row's message.
|
|
1401
|
+
*/
|
|
1402
|
+
const recordWithheld = (entry, raw, answered, cause) => {
|
|
1403
|
+
// The pair is the one the host-level writers file too (`withheldOpsFailure`, #2554): an upstream
|
|
1404
|
+
// trace handle the text carries is named on staff's row only, first, so either id finds the pair.
|
|
1405
|
+
// With no tenant, `recordFailure` writes the text as a staff row's.
|
|
1406
|
+
const { tenantId } = entry;
|
|
1407
|
+
recordFailure(tenantId ? withheldOpsFailure({ ...entry, tenantId }, raw, () => answered, entry.reference) : { ...entry, tenantId: null, message: raw }, cause);
|
|
1408
|
+
};
|
|
1409
|
+
/**
|
|
1410
|
+
* #2540: a failure whose text is not ours to hand a confined caller: what an upstream or a store
|
|
1411
|
+
* answered, often while the request reached scopes that are not the caller's (a promote's serve
|
|
1412
|
+
* and store backfill run across every install). The text goes to the `control-plane.unhandled`
|
|
1413
|
+
* log line and to an ops-failure row with no tenant on it, which only staff read, both under one
|
|
1414
|
+
* reference (`recordWithheld`). A `tenantId` the failure belongs to gets a row too, saying only
|
|
1415
|
+
* `answered(reference)`. The answer carries the reference, and the text too when the caller is
|
|
1416
|
+
* staff. Who the caller is is decided by the request's principal alone (`staffCaller`), never by a
|
|
1417
|
+
* scope or tenant the request names. The cause is marked recorded, so the 5xx backstop writes no
|
|
1418
|
+
* second row for it; a route that answers the failure as a 5xx of its own also sets `failureRecorded`.
|
|
1419
|
+
*/
|
|
1420
|
+
const withheldFailure = (c, entry, cause, answered) => {
|
|
1421
|
+
const reference = ulid();
|
|
1422
|
+
console.error('control-plane.unhandled', {
|
|
1423
|
+
method: c.req.method,
|
|
1424
|
+
path: c.req.path,
|
|
1425
|
+
reference,
|
|
1426
|
+
operation: entry.operation,
|
|
1427
|
+
stage: entry.stage ?? null,
|
|
1428
|
+
vertical: entry.vertical ?? null,
|
|
1429
|
+
version: entry.version ?? null,
|
|
1430
|
+
tenantId: entry.tenantId ?? null,
|
|
1431
|
+
scopeId: entry.scopeId ?? null,
|
|
1432
|
+
detail: entry.message,
|
|
1433
|
+
});
|
|
1434
|
+
const { message, ...rest } = entry;
|
|
1435
|
+
recordWithheld({ ...rest, actor: c.get('actor'), tenantId: answered ? (entry.tenantId ?? null) : null, reference }, message, answered?.(reference) ?? '', cause);
|
|
1436
|
+
return staffCaller(c) ? { reference, detail: message } : { reference };
|
|
1437
|
+
};
|
|
1309
1438
|
/**
|
|
1310
1439
|
* Whose failure the backstop is recording, so the dashboard's tenant-pinned failure view lists
|
|
1311
1440
|
* it. The path's tenant when it names one; otherwise the credential's own (a CI push token, a
|
|
@@ -1369,7 +1498,7 @@ export function createControlPlaneApi(options) {
|
|
|
1369
1498
|
// One error boundary for every route: adapters throw plain Errors, and each
|
|
1370
1499
|
// one is a fail-closed refusal that must reach the caller as a status, not a
|
|
1371
1500
|
// stack trace.
|
|
1372
|
-
app.onError((err, c) => {
|
|
1501
|
+
app.onError(async (err, c) => {
|
|
1373
1502
|
// A parse failure goes through the same builder as everything else (#113 phase 4):
|
|
1374
1503
|
// `toProblem` maps zod's `issues` onto the declared `errors: [{ path, message }]`
|
|
1375
1504
|
// extension, which is the shape the model documents and a client can act on. The raw
|
|
@@ -1382,6 +1511,31 @@ export function createControlPlaneApi(options) {
|
|
|
1382
1511
|
if (err instanceof CarrySourceChanged) {
|
|
1383
1512
|
return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
|
|
1384
1513
|
}
|
|
1514
|
+
// Whose failure this is, as the backstop files it, so a log line names the same tenant as its row.
|
|
1515
|
+
// Read only for an answer that is logged or filed: a 4xx refusal pays nothing.
|
|
1516
|
+
const subjectOf = async () => {
|
|
1517
|
+
const subject = await failureSubject(c).catch(() => ({ vertical: null, tenantId: null }));
|
|
1518
|
+
c.set('failureSubjectOf', subject);
|
|
1519
|
+
return subject;
|
|
1520
|
+
};
|
|
1521
|
+
// #2540: a refusal to judge what a promote or bind breaks names what could not be read, which may
|
|
1522
|
+
// be another tenant's app, and that read's own error. Every door that reaches it answers here.
|
|
1523
|
+
if (isExportBreakUnjudged(err)) {
|
|
1524
|
+
const subject = await subjectOf();
|
|
1525
|
+
const sentence = (reference) => `cannot say whom this change breaks: what an installed app imports could not be read, so nothing was changed. Retry (reference ${reference})`;
|
|
1526
|
+
const withheld = withheldFailure(c, {
|
|
1527
|
+
operation: `${c.req.method} ${c.req.routePath}`,
|
|
1528
|
+
stage: 'export-breaks',
|
|
1529
|
+
vertical: subject.vertical,
|
|
1530
|
+
tenantId: subject.tenantId,
|
|
1531
|
+
scopeId: c.req.param('scopeId') ?? null,
|
|
1532
|
+
status: 503,
|
|
1533
|
+
code: 'unavailable',
|
|
1534
|
+
message: messageOfThrow(err),
|
|
1535
|
+
}, err, sentence);
|
|
1536
|
+
c.set('failureRecorded', true);
|
|
1537
|
+
return refuse(c, 'unavailable', sentence(withheld.reference), withheld);
|
|
1538
|
+
}
|
|
1385
1539
|
const { status, body } = mapError(err);
|
|
1386
1540
|
// The 5xx backstop (registered first, above) records it, with this throw as the cause.
|
|
1387
1541
|
c.set('failure', err);
|
|
@@ -1394,9 +1548,16 @@ export function createControlPlaneApi(options) {
|
|
|
1394
1548
|
// (the 503 for a plane with no seal key, #603) passes through here too: its body already
|
|
1395
1549
|
// says what is wrong, and the tail line is what ties it to the request that hit it.
|
|
1396
1550
|
if (status >= 500) {
|
|
1551
|
+
const subject = await subjectOf();
|
|
1552
|
+
const reference = ulid();
|
|
1553
|
+
c.set('failureReference', reference);
|
|
1397
1554
|
console.error('control-plane.unhandled', {
|
|
1398
1555
|
method: c.req.method,
|
|
1399
1556
|
path: c.req.path,
|
|
1557
|
+
reference,
|
|
1558
|
+
tenantId: subject.tenantId,
|
|
1559
|
+
vertical: subject.vertical,
|
|
1560
|
+
scopeId: c.req.param('scopeId') ?? null,
|
|
1400
1561
|
detail: err instanceof Error ? err.message : String(err),
|
|
1401
1562
|
stack: err instanceof Error ? err.stack : undefined,
|
|
1402
1563
|
});
|
|
@@ -1531,11 +1692,13 @@ export function createControlPlaneApi(options) {
|
|
|
1531
1692
|
return c.json(await c.var.admin.listEntitlements(c.get('actor'), tenantId));
|
|
1532
1693
|
});
|
|
1533
1694
|
// -- the meters (§5, #38) --------------------------------------------------
|
|
1534
|
-
// Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`.
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1695
|
+
// Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`. Meter 3's parts are
|
|
1696
|
+
// read at their own routes, not here (`/meters/storage`, `/model-usage`, and the estimated
|
|
1697
|
+
// daily request rollup at `/meters/requests`); lake bytes have no reader yet, and exact API counts are not computable, since
|
|
1698
|
+
// reads emit nothing on the spine. Meter 4 has no route because it has no number
|
|
1699
|
+
// (it needs the cross-tenant order flow). See control-plane.md §5. Staff-only (absent
|
|
1700
|
+
// from BUILDER_ROUTES) — a fleet-wide revenue aggregate is the platform's own book, not
|
|
1701
|
+
// a builder's view of their installs.
|
|
1539
1702
|
//
|
|
1540
1703
|
// GET, and nothing is stored: D-30 is meter, do not bill, so a reading is recomputed
|
|
1541
1704
|
// per call and stamped with the instant it was taken.
|
|
@@ -1580,14 +1743,123 @@ export function createControlPlaneApi(options) {
|
|
|
1580
1743
|
if (!scope.vertical)
|
|
1581
1744
|
return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
1582
1745
|
const vertical = await verticalForScope(c, scope);
|
|
1583
|
-
if (vertical)
|
|
1584
|
-
|
|
1746
|
+
if (vertical) {
|
|
1747
|
+
try {
|
|
1748
|
+
return await vertical.databaseSize(scope.id);
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
// #2541: a deployment that holds nothing for a scope the directory has reaped or archived
|
|
1752
|
+
// since the listing (or does not have) reads 0; any other status holds a store and stays a
|
|
1753
|
+
// failure here, as does a directory read that fails.
|
|
1754
|
+
if (!isScopeNotHeld(err, scope.id))
|
|
1755
|
+
throw err;
|
|
1756
|
+
if (!(await notHeldIsBenign(() => c.var.admin.getScopeRecord(actor, q.tenantId, scope.id))))
|
|
1757
|
+
throw err;
|
|
1758
|
+
return 0;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1585
1761
|
if (delegatesToVerticals)
|
|
1586
1762
|
throw new Error(`no deployment resolves for vertical ${scope.vertical}`);
|
|
1587
1763
|
return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
|
|
1588
1764
|
},
|
|
1589
1765
|
}));
|
|
1590
1766
|
});
|
|
1767
|
+
// The daily request rollup (#2501): the stored per-(day, tenant, vertical, scope) request
|
|
1768
|
+
// counts the platform sweep reads from the router's Analytics Engine datapoints before they
|
|
1769
|
+
// age out, and the rollup's record of each day in the window. Staff-only like `/meters`. Every
|
|
1770
|
+
// count is an estimate (Analytics Engine samples), and the answer says so twice: `estimated`
|
|
1771
|
+
// on the reading and on every row. A directory read; nothing is queried live. `days` is what
|
|
1772
|
+
// tells a day without traffic from a day not yet rolled up.
|
|
1773
|
+
//
|
|
1774
|
+
// A page is at most half the directory's own read bound, so the one row past the page that
|
|
1775
|
+
// says the window holds more always fits under it.
|
|
1776
|
+
const REQUEST_ROLLUP_PAGE_MAX = REQUEST_ROLLUP_LIMIT_MAX / 2;
|
|
1777
|
+
// Unless a window is named, the 31 days ending at `until`, both ends included.
|
|
1778
|
+
const windowSince = (until) => new Date(Date.parse(`${until}T00:00:00Z`) - 30 * 86_400_000).toISOString().slice(0, 10);
|
|
1779
|
+
app.get('/meters/requests', async (c) => {
|
|
1780
|
+
const admin = c.var.admin;
|
|
1781
|
+
if (!admin.listRequestDays || !admin.listRequestRollupDays) {
|
|
1782
|
+
return refuseStatus(c, 501, 'this control plane keeps no request rollup');
|
|
1783
|
+
}
|
|
1784
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1785
|
+
const q = z
|
|
1786
|
+
.object({
|
|
1787
|
+
tenantId: tenantIdSchema.optional(),
|
|
1788
|
+
scopeId: scopeIdSchema.optional(),
|
|
1789
|
+
vertical: z.string().min(1).optional(),
|
|
1790
|
+
since: utcDay.optional(),
|
|
1791
|
+
until: utcDay.default(today),
|
|
1792
|
+
limit: z.coerce.number().int().min(1).max(REQUEST_ROLLUP_PAGE_MAX).default(REQUEST_ROLLUP_LIMIT_DEFAULT),
|
|
1793
|
+
})
|
|
1794
|
+
.parse({
|
|
1795
|
+
tenantId: c.req.query('tenantId') || undefined,
|
|
1796
|
+
scopeId: c.req.query('scopeId') || undefined,
|
|
1797
|
+
vertical: c.req.query('vertical') || undefined,
|
|
1798
|
+
since: c.req.query('since') || undefined,
|
|
1799
|
+
until: c.req.query('until') || undefined,
|
|
1800
|
+
limit: c.req.query('limit') || undefined,
|
|
1801
|
+
});
|
|
1802
|
+
const since = q.since ?? windowSince(q.until);
|
|
1803
|
+
if (since > q.until)
|
|
1804
|
+
return refuse(c, 'validation_failed', '`since` is after `until`');
|
|
1805
|
+
const actor = c.get('actor');
|
|
1806
|
+
// One row past the page says whether the window holds more than it returned.
|
|
1807
|
+
const rows = await admin.listRequestDays(actor, {
|
|
1808
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1809
|
+
...(q.scopeId ? { scopeId: q.scopeId } : {}),
|
|
1810
|
+
...(q.vertical ? { vertical: q.vertical } : {}),
|
|
1811
|
+
since,
|
|
1812
|
+
until: q.until,
|
|
1813
|
+
limit: q.limit + 1,
|
|
1814
|
+
});
|
|
1815
|
+
const days = await admin.listRequestRollupDays(actor, { since, until: q.until });
|
|
1816
|
+
return c.json(requestRollupReading.parse({
|
|
1817
|
+
estimated: true,
|
|
1818
|
+
since,
|
|
1819
|
+
until: q.until,
|
|
1820
|
+
rows: rows.slice(0, q.limit),
|
|
1821
|
+
truncated: rows.length > q.limit,
|
|
1822
|
+
days,
|
|
1823
|
+
}));
|
|
1824
|
+
});
|
|
1825
|
+
// The daily meter snapshot (#2501): meters 1 and 2 as the first scheduled pass of each UTC day
|
|
1826
|
+
// found them, one row per (day, tenant), and the record of each day in the window. Staff-only
|
|
1827
|
+
// like `/meters`. A directory read; nothing is recomputed, so a past day reads as it was then.
|
|
1828
|
+
// `days` is what tells a day no pass took from a day whose snapshot met no tenant. The page bound
|
|
1829
|
+
// leaves room under the directory's own for the one row past it.
|
|
1830
|
+
const METER_DAYS_PAGE_MAX = METER_DAYS_LIMIT_MAX / 2;
|
|
1831
|
+
app.get('/meters/days', async (c) => {
|
|
1832
|
+
const admin = c.var.admin;
|
|
1833
|
+
if (!admin.listMeterDays || !admin.listMeterDayTakes) {
|
|
1834
|
+
return refuseStatus(c, 501, 'this control plane keeps no meter snapshots');
|
|
1835
|
+
}
|
|
1836
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1837
|
+
const q = z
|
|
1838
|
+
.object({
|
|
1839
|
+
tenantId: tenantIdSchema.optional(),
|
|
1840
|
+
since: utcDay.optional(),
|
|
1841
|
+
until: utcDay.default(today),
|
|
1842
|
+
limit: z.coerce.number().int().min(1).max(METER_DAYS_PAGE_MAX).default(METER_DAYS_LIMIT_DEFAULT),
|
|
1843
|
+
})
|
|
1844
|
+
.parse({
|
|
1845
|
+
tenantId: c.req.query('tenantId') || undefined,
|
|
1846
|
+
since: c.req.query('since') || undefined,
|
|
1847
|
+
until: c.req.query('until') || undefined,
|
|
1848
|
+
limit: c.req.query('limit') || undefined,
|
|
1849
|
+
});
|
|
1850
|
+
const since = q.since ?? windowSince(q.until);
|
|
1851
|
+
if (since > q.until)
|
|
1852
|
+
return refuse(c, 'validation_failed', '`since` is after `until`');
|
|
1853
|
+
const actor = c.get('actor');
|
|
1854
|
+
const rows = await admin.listMeterDays(actor, {
|
|
1855
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1856
|
+
since,
|
|
1857
|
+
until: q.until,
|
|
1858
|
+
limit: q.limit + 1,
|
|
1859
|
+
});
|
|
1860
|
+
const days = await admin.listMeterDayTakes(actor, { since, until: q.until });
|
|
1861
|
+
return c.json(meterDaysReading.parse({ since, until: q.until, rows: rows.slice(0, q.limit), truncated: rows.length > q.limit, days }));
|
|
1862
|
+
});
|
|
1591
1863
|
// -- per-tenant stores (#301, #473) ----------------------------------------
|
|
1592
1864
|
// The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
|
|
1593
1865
|
// meant to answer for a staff surface: which database and which bucket hold this
|
|
@@ -3707,15 +3979,18 @@ export function createControlPlaneApi(options) {
|
|
|
3707
3979
|
// ready-gate and a fresh install without it is simply broken, so a failed mint must
|
|
3708
3980
|
// fail the provision rather than hand back a half-built scope.
|
|
3709
3981
|
const storeErrors = [];
|
|
3982
|
+
// #2540: the same failures as the scope's tenant may read them: the sentences, never the text.
|
|
3983
|
+
const storeSentences = [];
|
|
3710
3984
|
const mintBestEffort = async (stage, mint) => {
|
|
3711
3985
|
try {
|
|
3712
3986
|
return await mint();
|
|
3713
3987
|
}
|
|
3714
3988
|
catch (e) {
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3989
|
+
// #2540: what the store answered is staff's. A confined caller (a builder may repair any
|
|
3990
|
+
// install of its own vertical, whoever's) reads that the mint failed and the reference, and
|
|
3991
|
+
// the scope's tenant reads the same on its failure row, whoever made the request.
|
|
3992
|
+
const sentence = (reference) => `a declared store could not be minted (${stage}, reference ${reference})`;
|
|
3993
|
+
const withheld = withheldFailure(c, {
|
|
3719
3994
|
operation: 'scope.provision.stores',
|
|
3720
3995
|
stage,
|
|
3721
3996
|
tenantId,
|
|
@@ -3723,9 +3998,10 @@ export function createControlPlaneApi(options) {
|
|
|
3723
3998
|
vertical: scope.vertical,
|
|
3724
3999
|
version: scope.verticalVersionId ?? null,
|
|
3725
4000
|
status: e instanceof ControlPlaneError ? e.status : null,
|
|
3726
|
-
message,
|
|
3727
|
-
}, e);
|
|
3728
|
-
storeErrors.push(
|
|
4001
|
+
message: messageOfThrow(e),
|
|
4002
|
+
}, e, sentence);
|
|
4003
|
+
storeErrors.push(withheld.detail ? `${withheld.detail} (reference ${withheld.reference})` : sentence(withheld.reference));
|
|
4004
|
+
storeSentences.push(sentence(withheld.reference));
|
|
3729
4005
|
return undefined;
|
|
3730
4006
|
}
|
|
3731
4007
|
};
|
|
@@ -3803,8 +4079,60 @@ export function createControlPlaneApi(options) {
|
|
|
3803
4079
|
// Both halves failed. The store fault is usually the CAUSE (an unmigrated or
|
|
3804
4080
|
// unbound store is exactly what makes a vertical refuse its own reconcile), so
|
|
3805
4081
|
// it must not be the half that gets dropped.
|
|
3806
|
-
const
|
|
3807
|
-
|
|
4082
|
+
const stores = storeErrors.length ? ` (stores: ${storeErrors.join('; ')})` : '';
|
|
4083
|
+
// #2540: the vertical's answer about a scope is its tenant's own text. Staff, and a confined
|
|
4084
|
+
// caller repairing an install of its own tenant, read it as before.
|
|
4085
|
+
if (staffCaller(c) || confinedTenant(c.get('principal')) === tenantId) {
|
|
4086
|
+
const answer = `${e.message}${stores}`;
|
|
4087
|
+
// Staff are answered the store text too, which the tenant's row must not say: a 5xx the
|
|
4088
|
+
// backstop would file is filed here as a pair, the tenant's row with the sentences only.
|
|
4089
|
+
const tenantStores = storeSentences.length ? ` (stores: ${storeSentences.join('; ')})` : '';
|
|
4090
|
+
if (e.status >= 500 && e.status !== 501 && tenantStores !== stores) {
|
|
4091
|
+
const reference = ulid();
|
|
4092
|
+
recordWithheld({
|
|
4093
|
+
actor,
|
|
4094
|
+
operation: `${c.req.method} ${c.req.routePath}`,
|
|
4095
|
+
tenantId,
|
|
4096
|
+
scopeId,
|
|
4097
|
+
vertical: scope.vertical,
|
|
4098
|
+
version: scope.verticalVersionId ?? null,
|
|
4099
|
+
status: e.status,
|
|
4100
|
+
reference,
|
|
4101
|
+
}, answer, `${e.message}${tenantStores}`, e);
|
|
4102
|
+
c.set('failureRecorded', true);
|
|
4103
|
+
return refuseStatus(c, failedWith(c, e), answer, { reference });
|
|
4104
|
+
}
|
|
4105
|
+
return refuseStatus(c, failedWith(c, e), answer);
|
|
4106
|
+
}
|
|
4107
|
+
// A builder repairing another tenant's install of its vertical reads the status, the store
|
|
4108
|
+
// sentences and a reference; what that deployment answered is staff's.
|
|
4109
|
+
const sentence = (reference) => `the reconcile of this scope failed (${e.status})${stores} (reference ${reference})`;
|
|
4110
|
+
if (e.status < 500) {
|
|
4111
|
+
// A refusal, which the 5xx backstop leaves unrecorded for staff too: logged, no row.
|
|
4112
|
+
const reference = ulid();
|
|
4113
|
+
console.warn('control-plane.withheld', {
|
|
4114
|
+
method: c.req.method,
|
|
4115
|
+
path: c.req.path,
|
|
4116
|
+
reference,
|
|
4117
|
+
operation: 'scope.provision.reconcile',
|
|
4118
|
+
tenantId,
|
|
4119
|
+
scopeId,
|
|
4120
|
+
status: e.status,
|
|
4121
|
+
detail: e.message,
|
|
4122
|
+
});
|
|
4123
|
+
return refuseStatus(c, e.status, sentence(reference), { reference });
|
|
4124
|
+
}
|
|
4125
|
+
const withheld = withheldFailure(c, {
|
|
4126
|
+
operation: 'scope.provision.reconcile',
|
|
4127
|
+
tenantId,
|
|
4128
|
+
scopeId,
|
|
4129
|
+
vertical: scope.vertical,
|
|
4130
|
+
version: scope.verticalVersionId ?? null,
|
|
4131
|
+
status: e.status,
|
|
4132
|
+
message: e.message,
|
|
4133
|
+
}, e, sentence);
|
|
4134
|
+
c.set('failureRecorded', true);
|
|
4135
|
+
return refuseStatus(c, e.status, sentence(withheld.reference), withheld);
|
|
3808
4136
|
}
|
|
3809
4137
|
throw e;
|
|
3810
4138
|
}
|
|
@@ -4507,6 +4835,7 @@ export function createControlPlaneApi(options) {
|
|
|
4507
4835
|
scopeId: snapId,
|
|
4508
4836
|
kind: opts.kind ?? 'archive',
|
|
4509
4837
|
vertical: scope.vertical,
|
|
4838
|
+
verticalSource: 'copy',
|
|
4510
4839
|
jurisdiction: scope.jurisdiction,
|
|
4511
4840
|
forkedFrom: scope.id,
|
|
4512
4841
|
forkedAt: new Date().toISOString(),
|
|
@@ -6901,6 +7230,20 @@ export function createControlPlaneApi(options) {
|
|
|
6901
7230
|
await c.var.admin.deleteVertical(c.get('actor'), slug);
|
|
6902
7231
|
return c.json({ slug, deleted: true });
|
|
6903
7232
|
});
|
|
7233
|
+
// Who owned a vertical slug when (#2501): every period, opened when the slug was registered and
|
|
7234
|
+
// closed when it was deleted, so a past period bills the builder who owned the vertical then.
|
|
7235
|
+
// Staff-only, like `/meters`: absent from BUILDER_ROUTES and from the tenant credential's pins.
|
|
7236
|
+
// Served for a deleted slug too, which is the point; a slug never registered has no period.
|
|
7237
|
+
// The FULL slug, read as given: unlike the routes behind `resolveVerticalId`, the tenant pin is
|
|
7238
|
+
// ignored, since it resolves a bare name through the registry row a deleted slug no longer has.
|
|
7239
|
+
// A bare name is read as a slug of its own; one never registered answers with no periods.
|
|
7240
|
+
app.get('/verticals/:slug/owners', async (c) => {
|
|
7241
|
+
const admin = c.var.admin;
|
|
7242
|
+
if (!admin.listVerticalOwners)
|
|
7243
|
+
return refuseStatus(c, 501, 'this control plane keeps no ownership history');
|
|
7244
|
+
const slug = verticalSlug.parse(c.req.param('slug'));
|
|
7245
|
+
return c.json(verticalOwnersReading.parse({ slug, owners: await admin.listVerticalOwners(c.get('actor'), slug) }));
|
|
7246
|
+
});
|
|
6904
7247
|
app.get('/verticals/:slug/channels', async (c) => {
|
|
6905
7248
|
const p = c.get('principal');
|
|
6906
7249
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
@@ -6987,6 +7330,40 @@ export function createControlPlaneApi(options) {
|
|
|
6987
7330
|
await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
|
|
6988
7331
|
}
|
|
6989
7332
|
};
|
|
7333
|
+
/**
|
|
7334
|
+
* Upload `bundle` to the script `ref`, a deployment of `slug`, with what the control plane's
|
|
7335
|
+
* settings give it for the envelope's verifier (RFC wire-signing step 2, follow-on 3:
|
|
7336
|
+
* `wireUploadFor`) added to its bindings. Both uploads of a vertical's script go through here, so
|
|
7337
|
+
* no upload path can leave the lists out. A refused setting or refused lists give nothing, and are
|
|
7338
|
+
* logged and recorded as an operational failure by reason, never with a key. Answers what the
|
|
7339
|
+
* upload gave, for its record (`recordWireUpload`).
|
|
7340
|
+
*/
|
|
7341
|
+
const deployVerticalScript = async (deploy, actor, slug, ref, bundle, inPlace) => {
|
|
7342
|
+
const wire = await wireUploadFor(options.wireUpload, { vertical: slug, deploymentRef: ref, bundle });
|
|
7343
|
+
if (wire.refusal !== null) {
|
|
7344
|
+
console.error(JSON.stringify({ substrat: 'wire-upload', vertical: slug, deploymentRef: ref, withheld: wire.record.withheld, reason: wire.refusal }));
|
|
7345
|
+
recordFailure({ actor, operation: 'deploy.wire-upload', stage: wire.record.withheld, vertical: slug, message: `no key lists given to ${ref}: ${wire.refusal}` });
|
|
7346
|
+
}
|
|
7347
|
+
await deploy(ref, { ...bundle, bindings: [...bundle.bindings, ...wire.bindings] }, inPlace);
|
|
7348
|
+
return wire;
|
|
7349
|
+
};
|
|
7350
|
+
/**
|
|
7351
|
+
* The capability record of an upload that succeeded (RFC wire-signing §7, Q8). A write that fails
|
|
7352
|
+
* never fails the upload, which is done: it is logged and recorded as an operational failure, and
|
|
7353
|
+
* the readiness report reads a deployment with no record as unknown, never as ready.
|
|
7354
|
+
*/
|
|
7355
|
+
const recordWireUpload = async (admin, actor, versionId, wire) => {
|
|
7356
|
+
if (!admin.recordWireAuth)
|
|
7357
|
+
return;
|
|
7358
|
+
const { vertical, deploymentRef, record } = wire;
|
|
7359
|
+
try {
|
|
7360
|
+
await admin.recordWireAuth(actor, { deploymentRef, verticalSlug: vertical, versionId, record, recordedAt: new Date().toISOString() });
|
|
7361
|
+
}
|
|
7362
|
+
catch (error) {
|
|
7363
|
+
console.error(JSON.stringify({ substrat: 'wire-upload-record-failed', vertical, deploymentRef, error: error instanceof Error ? error.name : 'error' }));
|
|
7364
|
+
recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
|
|
7365
|
+
}
|
|
7366
|
+
};
|
|
6990
7367
|
/**
|
|
6991
7368
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
6992
7369
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -7041,7 +7418,7 @@ export function createControlPlaneApi(options) {
|
|
|
7041
7418
|
: { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
7042
7419
|
// #2210: the store scripts first, so the vertical is never served before what it reaches.
|
|
7043
7420
|
await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
|
|
7044
|
-
await options.deployVertical
|
|
7421
|
+
const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
|
|
7045
7422
|
entry: manifest.entry,
|
|
7046
7423
|
compatibilityDate: manifest.compatibilityDate,
|
|
7047
7424
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
@@ -7095,6 +7472,7 @@ export function createControlPlaneApi(options) {
|
|
|
7095
7472
|
}, serving
|
|
7096
7473
|
? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
|
|
7097
7474
|
: undefined);
|
|
7475
|
+
await recordWireUpload(admin, actor, versionId, wire);
|
|
7098
7476
|
const addedClasses = serving ? doClasses.some((cls) => !serving.doClasses.includes(cls)) : false;
|
|
7099
7477
|
await admin.setVerticalServing(actor, slug, {
|
|
7100
7478
|
ref,
|
|
@@ -7154,12 +7532,98 @@ export function createControlPlaneApi(options) {
|
|
|
7154
7532
|
return { minted };
|
|
7155
7533
|
}
|
|
7156
7534
|
catch (e) {
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
return {
|
|
7535
|
+
// The sweep mints across every installed tenant, so what failed is withheld from a confined
|
|
7536
|
+
// caller (#2540): it reads that the backfill failed, and the reference.
|
|
7537
|
+
const withheld = withheldFailure(c, { operation: 'promote.store-backfill', vertical: slug, message: messageOfThrow(e) }, e);
|
|
7538
|
+
return {
|
|
7539
|
+
minted: [],
|
|
7540
|
+
error: `${withheld.detail ?? 'the stores this version declares could not be minted for every install'} (reference ${withheld.reference})`,
|
|
7541
|
+
};
|
|
7161
7542
|
}
|
|
7162
7543
|
};
|
|
7544
|
+
/**
|
|
7545
|
+
* #2446: the scopes a prod promote of `versionId` would hold at their reconcile, judged before
|
|
7546
|
+
* anything moves. A pushed vertical's reconcile defines the incoming version's role set in each
|
|
7547
|
+
* scope it runs in (`refuseReservedRoleSet`), and refuses it where the scope's stored roles and
|
|
7548
|
+
* seats forbid it (#2525: a seated person's role made `holder: 'system'`, among others), which
|
|
7549
|
+
* holds that scope's whole reconcile while it serves the new code. So each scope the promote moves
|
|
7550
|
+
* onto the incoming version is asked for that state (`/internal/role-state`) and judged with the
|
|
7551
|
+
* reconcile's own rule and inputs (`releaseRoleSetRefusal`: the incoming registry's roles and
|
|
7552
|
+
* marks, which the push holds to the module declarations it carries).
|
|
7553
|
+
*
|
|
7554
|
+
* The scopes moved are the ones the in-place serve and the adopt after it move: every live primary
|
|
7555
|
+
* scope of the vertical already on its serving script, and, for a private vertical, every one the
|
|
7556
|
+
* owning tenant runs. Only a dispatch-backed version served in place moves them here; an embedded
|
|
7557
|
+
* vertical's roles are its host's, defined by no reconcile of the registry, so it is asked nothing.
|
|
7558
|
+
* Nor is a release no stored state can refuse (`releaseRolesAskScopes`), so a vertical with no
|
|
7559
|
+
* system-held role pays no read.
|
|
7560
|
+
*
|
|
7561
|
+
* At most `roleStateScopeCap` scopes are read, `ROLE_STATE_CONCURRENCY` at a time in (tenant,
|
|
7562
|
+
* scope) order; more is a 503 naming the two ways through (raise the cap, or staff's
|
|
7563
|
+
* `acknowledge.roleStateUnjudged`, which skips the read and is audited), never a partial
|
|
7564
|
+
* judgement. A deployment that cannot answer the read (one that predates the route, a 404, or one
|
|
7565
|
+
* whose scope host predates the method, its 501) is counted instead; its reconcile still refuses.
|
|
7566
|
+
* Any other failed read is collected in `failed`, with what the deployment said, and refuses the
|
|
7567
|
+
* promote with nothing moved. Nothing here is phrased for a caller: the route narrows every answer
|
|
7568
|
+
* to the scopes the caller may see, and what a deployment said reaches only the log and staff.
|
|
7569
|
+
*/
|
|
7570
|
+
const releaseRoleRefusalsOf = async (c, slug, versionId) => {
|
|
7571
|
+
const none = { refused: [], failed: [], unread: 0 };
|
|
7572
|
+
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
7573
|
+
return none; // no in-place serve moves anything
|
|
7574
|
+
const actor = c.get('actor');
|
|
7575
|
+
const incoming = await host.admin.getVersion(actor, versionId, slug);
|
|
7576
|
+
if (!incoming?.deploymentRef)
|
|
7577
|
+
return none;
|
|
7578
|
+
const manifestJson = await host.admin.versionManifest(actor, slug, versionId);
|
|
7579
|
+
const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
|
|
7580
|
+
if (!registry || !releaseRolesAskScopes(registry))
|
|
7581
|
+
return none;
|
|
7582
|
+
const v = await verticalOf(actor, slug);
|
|
7583
|
+
const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
|
|
7584
|
+
const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
|
|
7585
|
+
.filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
|
|
7586
|
+
.sort((a, b) => (a.tenantId === b.tenantId ? a.id.localeCompare(b.id) : a.tenantId.localeCompare(b.tenantId)));
|
|
7587
|
+
const cap = options.roleStateScopeCap ?? ROLE_STATE_SCOPE_CAP;
|
|
7588
|
+
if (moved.length > cap) {
|
|
7589
|
+
throw new ControlPlaneError(503, `this version declares a system-held role, so a promote judges the roles of every scope it moves before moving ` +
|
|
7590
|
+
`any, and it would move ${moved.length}, more than the ${cap} one promote reads. Nothing was promoted. Raise the control ` +
|
|
7591
|
+
'plane\'s `roleStateScopeCap`, or, as staff, promote with `acknowledge.roleStateUnjudged`, which skips the read and ' +
|
|
7592
|
+
'is recorded in the promote\'s audit row');
|
|
7593
|
+
}
|
|
7594
|
+
const refused = [];
|
|
7595
|
+
const failed = [];
|
|
7596
|
+
let unread = 0;
|
|
7597
|
+
for (let i = 0; i < moved.length; i += ROLE_STATE_CONCURRENCY) {
|
|
7598
|
+
const batch = moved.slice(i, i + ROLE_STATE_CONCURRENCY);
|
|
7599
|
+
const states = await Promise.all(batch.map(async (s) => {
|
|
7600
|
+
try {
|
|
7601
|
+
const client = await verticalForScope(c, s);
|
|
7602
|
+
if (!client)
|
|
7603
|
+
return { failed: 'no deployment serves it' };
|
|
7604
|
+
return await client.roleState({ tenantId: s.tenantId, scopeId: s.id });
|
|
7605
|
+
}
|
|
7606
|
+
catch (e) {
|
|
7607
|
+
return { failed: e instanceof Error ? e.message : String(e) };
|
|
7608
|
+
}
|
|
7609
|
+
}));
|
|
7610
|
+
batch.forEach((s, j) => {
|
|
7611
|
+
const state = states[j];
|
|
7612
|
+
if (state === 'predates')
|
|
7613
|
+
return void unread++;
|
|
7614
|
+
if ('failed' in state) {
|
|
7615
|
+
console.warn('promote.role-state.unreadable', { slug, versionId, tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
|
|
7616
|
+
return void failed.push({ tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
|
|
7617
|
+
}
|
|
7618
|
+
const refusal = releaseRoleSetRefusal(registry, state);
|
|
7619
|
+
if (refusal)
|
|
7620
|
+
refused.push({ tenantId: s.tenantId, scopeId: s.id, refusal });
|
|
7621
|
+
});
|
|
7622
|
+
}
|
|
7623
|
+
if (unread > 0)
|
|
7624
|
+
console.warn('promote.role-state.predates', { slug, versionId, scopes: unread });
|
|
7625
|
+
return { refused, failed, unread };
|
|
7626
|
+
};
|
|
7163
7627
|
/**
|
|
7164
7628
|
* Which installed apps promoting `versionId` to `channel` would break: the promote gate's own
|
|
7165
7629
|
* answer (`promotionImpact`, judged from the channel's previous version), and — for a PRIVATE,
|
|
@@ -7265,6 +7729,23 @@ export function createControlPlaneApi(options) {
|
|
|
7265
7729
|
return { affected: visible, ...(otherTenants ? { otherTenants } : {}) };
|
|
7266
7730
|
};
|
|
7267
7731
|
const exportBreaks = acknowledge?.exportBreak ? await breaksOf() : null;
|
|
7732
|
+
// #2446: the scopes whose reconcile this version's roles would hold, before anything moves. The
|
|
7733
|
+
// digest and export-break acknowledgements do not pass it: the way through is taking the seats
|
|
7734
|
+
// back, or a release that keeps the role as each scope stores it. It runs ahead of the host's
|
|
7735
|
+
// admission and digest gates rather than being skipped when they look set to refuse: those are
|
|
7736
|
+
// judged inside `promoteVersion` against the channel as it stands then, so a read skipped on a
|
|
7737
|
+
// prediction would let a promote through unjudged if an admit or a channel move landed between.
|
|
7738
|
+
// The read is paid only by a release declaring a system-held role, at most the cap.
|
|
7739
|
+
// `roleStateUnjudged` skips the read: staff only, and recorded with the promote's acknowledgements.
|
|
7740
|
+
if (acknowledge?.roleStateUnjudged && confinedTenant(p) !== null) {
|
|
7741
|
+
return refuse(c, 'forbidden', 'acknowledge.roleStateUnjudged is for staff: it skips the role-state read a promote judges its roles with');
|
|
7742
|
+
}
|
|
7743
|
+
const roles = acknowledge?.roleStateUnjudged
|
|
7744
|
+
? { refused: [], failed: [], unread: 0 }
|
|
7745
|
+
: await releaseRoleRefusalsOf(c, slug, versionId);
|
|
7746
|
+
const answer = roleStateAnswer(roles, confinedTenant(p));
|
|
7747
|
+
if (answer)
|
|
7748
|
+
return refuseStatus(c, answer.status, answer.message, answer.extra);
|
|
7268
7749
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
7269
7750
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
7270
7751
|
// surface as a 4xx through mapError, not a 500.
|
|
@@ -7276,10 +7757,16 @@ export function createControlPlaneApi(options) {
|
|
|
7276
7757
|
throw err;
|
|
7277
7758
|
// The refusal stands whether or not the listing can be read. A listing that fails turns
|
|
7278
7759
|
// into a note, never into a 500 that would hide that the promote was refused.
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7760
|
+
// The listing is read across every tenant that installed the vertical, so what failed is
|
|
7761
|
+
// withheld from a confined caller (#2540).
|
|
7762
|
+
const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => {
|
|
7763
|
+
const withheld = withheldFailure(c, { operation: 'promote.export-breaks', vertical: slug, version: versionId, message: messageOfThrow(e) }, e);
|
|
7764
|
+
return {
|
|
7765
|
+
exportBreaks: null,
|
|
7766
|
+
exportBreaksUnavailable: `which apps it breaks could not be read: ${withheld.detail ?? 'the platform recorded why'} ` +
|
|
7767
|
+
`(reference ${withheld.reference})`,
|
|
7768
|
+
};
|
|
7769
|
+
});
|
|
7283
7770
|
return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
|
|
7284
7771
|
}
|
|
7285
7772
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
@@ -7304,9 +7791,21 @@ export function createControlPlaneApi(options) {
|
|
|
7304
7791
|
backfill = await backfillFleetStores(c, slug);
|
|
7305
7792
|
}
|
|
7306
7793
|
catch (e) {
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7794
|
+
// The serve, the adopt and the backfill reach every install of the vertical, whoever's, so
|
|
7795
|
+
// what failed is withheld from a confined caller (#2540), and the tenant the backstop would
|
|
7796
|
+
// have filed it under reads only the sentence.
|
|
7797
|
+
const sentence = (reference) => `promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry (reference ${reference})`;
|
|
7798
|
+
const withheld = withheldFailure(c, {
|
|
7799
|
+
operation: 'promote.serve',
|
|
7800
|
+
stage: 'serve-in-place',
|
|
7801
|
+
vertical: slug,
|
|
7802
|
+
version: versionId,
|
|
7803
|
+
tenantId: (await failureSubject(c)).tenantId,
|
|
7804
|
+
status: 502,
|
|
7805
|
+
message: messageOfThrow(e),
|
|
7806
|
+
}, e, sentence);
|
|
7807
|
+
c.set('failureRecorded', true);
|
|
7808
|
+
return refuseStatus(c, 502, sentence(withheld.reference), withheld);
|
|
7310
7809
|
}
|
|
7311
7810
|
}
|
|
7312
7811
|
// #1756: served, and every install that could move has moved and been backfilled, but one
|
|
@@ -7350,6 +7849,8 @@ export function createControlPlaneApi(options) {
|
|
|
7350
7849
|
...promoted,
|
|
7351
7850
|
// What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
|
|
7352
7851
|
...(exportBreaks ? { exportBreaks } : {}),
|
|
7852
|
+
// #2446: scopes whose deployment predates the role-state read were moved unjudged, in counts.
|
|
7853
|
+
...(roles.unread > 0 ? { roleStateUnread: { scopes: roles.unread, note: ROLE_STATE_UNREAD_NOTE } } : {}),
|
|
7353
7854
|
...(backfill.minted.length || backfill.error
|
|
7354
7855
|
? {
|
|
7355
7856
|
storeBackfill: {
|
|
@@ -7584,8 +8085,9 @@ export function createControlPlaneApi(options) {
|
|
|
7584
8085
|
// #2210: the store scripts first, outside the vertical upload's try, so a store failure
|
|
7585
8086
|
// answers as itself and the vertical is not uploaded at all.
|
|
7586
8087
|
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
|
|
8088
|
+
let wire;
|
|
7587
8089
|
try {
|
|
7588
|
-
await options.deployVertical(deploymentRef, {
|
|
8090
|
+
wire = await deployVerticalScript(options.deployVertical, c.get('actor'), slug, deploymentRef, {
|
|
7589
8091
|
entry: manifest.entry,
|
|
7590
8092
|
compatibilityDate: manifest.compatibilityDate,
|
|
7591
8093
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
@@ -7689,6 +8191,7 @@ export function createControlPlaneApi(options) {
|
|
|
7689
8191
|
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
7690
8192
|
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
7691
8193
|
});
|
|
8194
|
+
await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
|
|
7692
8195
|
// Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
|
|
7693
8196
|
// surfaces, name any surface that hostnames are still bound to but the declaration
|
|
7694
8197
|
// dropped — the URL keeps resolving (routing never keys on the declaration), it just
|
|
@@ -7791,7 +8294,9 @@ export function createControlPlaneApi(options) {
|
|
|
7791
8294
|
}
|
|
7792
8295
|
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
7793
8296
|
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
7794
|
-
// merged stream back. Capped because each extra service is another backend query
|
|
8297
|
+
// merged stream back. Capped because each extra service is another backend query, and
|
|
8298
|
+
// the Cloudflare reader may add a few more to fill the merged page (at most
|
|
8299
|
+
// `MAX_FILL_QUERIES` per read, whatever the number of services).
|
|
7795
8300
|
const asked = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
|
|
7796
8301
|
const ownedLogServices = await ownedServices(c.get('principal'), c.get('actor'));
|
|
7797
8302
|
// A confined principal reads only its own services' lines. An ask that names none
|
|
@@ -7815,7 +8320,12 @@ export function createControlPlaneApi(options) {
|
|
|
7815
8320
|
hours: c.req.query('hours'),
|
|
7816
8321
|
limit: c.req.query('limit'),
|
|
7817
8322
|
});
|
|
7818
|
-
|
|
8323
|
+
const { events, incomplete } = await options.observability.recentLogs(input);
|
|
8324
|
+
// The body stays the list every caller reads; a read that stopped short of vouching for the
|
|
8325
|
+
// whole window says so beside it, so a short page is never silently "nothing more".
|
|
8326
|
+
if (incomplete)
|
|
8327
|
+
c.header(LOGS_INCOMPLETE_HEADER, '1');
|
|
8328
|
+
return c.json(events);
|
|
7819
8329
|
});
|
|
7820
8330
|
// -- the TENANT grain (observability.md §3 view 4, §4.2) -----------------------
|
|
7821
8331
|
//
|
|
@@ -9742,7 +10252,12 @@ export function createControlPlaneApi(options) {
|
|
|
9742
10252
|
cursor: c.req.query('cursor'),
|
|
9743
10253
|
order: c.req.query('order'),
|
|
9744
10254
|
});
|
|
9745
|
-
const entries = await c.var.admin.listOpsFailures(c.get('actor'),
|
|
10255
|
+
const entries = await c.var.admin.listOpsFailures(c.get('actor'), {
|
|
10256
|
+
...filter,
|
|
10257
|
+
// #2540: staff's `reference` also finds a pair by the upstream handle its staff row names.
|
|
10258
|
+
// The text that matches is staff's, so a confined caller's search stays exact.
|
|
10259
|
+
...(staffCaller(c) ? { referenceInText: true } : {}),
|
|
10260
|
+
});
|
|
9746
10261
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
9747
10262
|
});
|
|
9748
10263
|
// -- sweep runs (#1232): the durable record of what a sweep pass touched -------
|