@substrat-run/control-plane-api 0.165.0 → 0.167.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 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +496 -49
- package/dist/api.js.map +1 -1
- package/dist/connection-binding-options.d.ts +13 -0
- package/dist/connection-binding-options.d.ts.map +1 -1
- package/dist/connection-binding-options.js +17 -0
- package/dist/connection-binding-options.js.map +1 -1
- package/dist/deploy.d.ts +2 -4
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +0 -2
- package/dist/deploy.js.map +1 -1
- package/dist/github-app.d.ts +31 -0
- package/dist/github-app.d.ts.map +1 -0
- package/dist/github-app.js +86 -0
- package/dist/github-app.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/source-builds.d.ts +6 -0
- package/dist/source-builds.d.ts.map +1 -0
- package/dist/source-builds.js +55 -0
- package/dist/source-builds.js.map +1 -0
- package/dist/staff-client.d.ts +2 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/store-rebuild.d.ts +7 -2
- package/dist/store-rebuild.d.ts.map +1 -1
- package/dist/store-rebuild.js +6 -3
- package/dist/store-rebuild.js.map +1 -1
- package/dist/store-releases.d.ts +56 -7
- package/dist/store-releases.d.ts.map +1 -1
- package/dist/store-releases.js +221 -20
- package/dist/store-releases.js.map +1 -1
- package/dist/store-script.d.ts +37 -6
- package/dist/store-script.d.ts.map +1 -1
- package/dist/store-script.js +50 -27
- package/dist/store-script.js.map +1 -1
- package/package.json +9 -9
package/dist/api.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
+
import { sourceBuildRequest } from '@substrat-run/contracts';
|
|
1
2
|
import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
2
|
-
import { connectionBindingOptions } from './connection-binding-options.js';
|
|
3
|
+
import { bindingChangeRefusal, connectionBindingOptions, refusesBindingChange } from './connection-binding-options.js';
|
|
3
4
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
4
5
|
import { Hono } from 'hono';
|
|
5
|
-
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS,
|
|
6
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeCatalogOperation, storeDriverBasisAdmits, 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, connectionSendTest, connectionSendTestResult, connectionSenders, 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, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
|
|
6
7
|
import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
|
|
7
8
|
import { ENGINE_ENTITLEMENT_KEYS, bareSlug, declarableEntitlementKey, entitlementNamespaceRefusal, grantableEntitlements } from '@substrat-run/contracts';
|
|
8
9
|
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
9
10
|
import { attributeFailure } from './failure-attribution.js';
|
|
10
11
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
11
|
-
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, listedPromoteRefusal, 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';
|
|
12
|
+
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, listedPromoteRefusal, isExportBreakUnjudged, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, REQUEST_ROLLUP_LIMIT_DEFAULT, REQUEST_ROLLUP_LIMIT_MAX, STORAGE_GAUGE_RETENTION_MONTHS, MODEL_USAGE_RETENTION_DAYS, BILLING_ROLLUP_RETENTION_MONTHS, METER_DAYS_LIMIT_DEFAULT, METER_DAYS_LIMIT_MAX, releaseRolesAskScopes, releaseRoleSetRefusal, withheldOpsFailure, } from '@substrat-run/kernel';
|
|
12
13
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
13
14
|
import { mintTenantToken } from './tenant-token.js';
|
|
14
15
|
import { beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
|
|
15
16
|
import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
|
|
16
17
|
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
18
|
+
import { liveApprovalOf } from './store-script.js';
|
|
17
19
|
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
18
|
-
import { PUSH_HOLD_TTL_MS, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
|
|
20
|
+
import { PUSH_HOLD_TTL_MS, STORE_RELEASE_STORED_MAX_BYTES, approveStoredDriverRelease, readStoreDriverApprovals, runStoreDriverRevoke, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
|
|
19
21
|
import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
|
|
20
22
|
import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
|
|
21
23
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
@@ -76,6 +78,8 @@ import { queryLakeEvents } from './lake-gateway.js';
|
|
|
76
78
|
import { LakeQueryError } from './r2-sql-engine.js';
|
|
77
79
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
78
80
|
import { isSocialRelayVertical, reconcileScopeSocialRelay, relayHostnames, } from './social-relay.js';
|
|
81
|
+
/** The body of a connection's test message (#2343), plain enough to be the same in HTML and text. */
|
|
82
|
+
const SEND_TEST_TEXT = 'This is a test message from a mail connection. It reached you, so its sender address can send.';
|
|
79
83
|
/**
|
|
80
84
|
* Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
|
|
81
85
|
* The CLI reads these by the same spelling (`packages/cli/src/version.ts`); exported so a
|
|
@@ -700,6 +704,9 @@ const TENANT_ROUTES = [
|
|
|
700
704
|
// #2100: the PUBLIC certificate a connection signs in with — same pin as the credential view.
|
|
701
705
|
{ method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/certificate$/, pin: 'path' },
|
|
702
706
|
{ method: 'POST', re: /\/tenants\/[^/]+\/connections\/[^/]+\/verify$/, pin: 'path' },
|
|
707
|
+
// #2343: a mail connection's test send. The dashboard sends it only to the signed-in person.
|
|
708
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/connections\/[^/]+\/send-test$/, pin: 'path' },
|
|
709
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/senders$/, pin: 'path' },
|
|
703
710
|
// A vertical's mailed connect links (connections.md §3.5.4): the consent landing's liveness
|
|
704
711
|
// read, the callback's spend and its undo, and the integrations card's list and revoke.
|
|
705
712
|
{ method: 'GET', re: /\/tenants\/[^/]+\/connect-links$/, pin: 'path' },
|
|
@@ -715,6 +722,8 @@ const TENANT_ROUTES = [
|
|
|
715
722
|
{ method: 'POST', re: /\/tenants\/[^/]+\/orgs$/, pin: 'path' },
|
|
716
723
|
{ method: 'POST', re: /\/tenants\/[^/]+\/role-assignments$/, pin: 'path' },
|
|
717
724
|
{ method: 'DELETE', re: /\/tenants\/[^/]+\/role-assignments$/, pin: 'path' },
|
|
725
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/entitlements$/, pin: 'path' },
|
|
726
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/metered-usage$/, pin: 'path' },
|
|
718
727
|
// An install's entitlement. The handler narrows WHICH key a tenant credential may
|
|
719
728
|
// grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
|
|
720
729
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
|
|
@@ -1147,7 +1156,7 @@ export function createControlPlaneApi(options) {
|
|
|
1147
1156
|
const advisory = options.cliAdvisory;
|
|
1148
1157
|
app.use('*', async (c, next) => {
|
|
1149
1158
|
await next();
|
|
1150
|
-
c.header(PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER);
|
|
1159
|
+
c.header(PLATFORM_FEATURES_HEADER, [PLATFORM_FEATURE_SCOPE_SWEEPER, ...(options.sourceBuilds ? [PLATFORM_FEATURE_SOURCE_BUILDS] : [])].join(','));
|
|
1151
1160
|
if (advisory?.minVersion)
|
|
1152
1161
|
c.header(CLI_MIN_VERSION_HEADER, advisory.minVersion);
|
|
1153
1162
|
if (advisory?.latestVersion)
|
|
@@ -1238,6 +1247,8 @@ export function createControlPlaneApi(options) {
|
|
|
1238
1247
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
|
|
1239
1248
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
|
|
1240
1249
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
|
|
1250
|
+
{ method: 'GET', re: /\/projects$/ },
|
|
1251
|
+
{ method: 'POST', re: /\/projects$/ },
|
|
1241
1252
|
{ method: 'POST', re: /\/verticals\/[^/]+\/deploy$/ },
|
|
1242
1253
|
// A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5); the
|
|
1243
1254
|
// `listing` flip stays staff-only. Ownership is checked in the handler.
|
|
@@ -1679,6 +1690,168 @@ export function createControlPlaneApi(options) {
|
|
|
1679
1690
|
});
|
|
1680
1691
|
// -- entitlements (§4.3) ---------------------------------------------------
|
|
1681
1692
|
app.get('/tenants/:tenantId/entitlements', async (c) => c.json(await c.var.admin.listEntitlements(c.get('actor'), tenantIdSchema.parse(c.req.param('tenantId')))));
|
|
1693
|
+
// One tenant's persisted usage, with no price-bearing fields (#2689). This read never
|
|
1694
|
+
// wakes a scope or writes a row. A gauge projection is capped at 501 narrow scope rows;
|
|
1695
|
+
// request rows at 10,000 plus 30 status rows; model pages at 25 x 501: at most 28
|
|
1696
|
+
// directory calls and 23,056 projected rows. Labels are capped at the read source,
|
|
1697
|
+
// and no staff ledger row crosses the RPC. The 5-second cooperative budget stops new
|
|
1698
|
+
// reads, though it cannot interrupt one in flight. Every cap marks a lower bound.
|
|
1699
|
+
// The dashboard credential reaches this path only when its signed tenant pin matches.
|
|
1700
|
+
app.get('/tenants/:tenantId/metered-usage', async (c) => {
|
|
1701
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1702
|
+
if (outsideTenant(c.get('principal'), tenantId))
|
|
1703
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
1704
|
+
const admin = c.var.admin;
|
|
1705
|
+
const actor = c.get('actor');
|
|
1706
|
+
const usageNow = options.meteredUsageNow ?? (() => performance.now());
|
|
1707
|
+
const deadline = usageNow() + 5_000;
|
|
1708
|
+
const readAt = new Date().toISOString();
|
|
1709
|
+
const until = readAt.slice(0, 10);
|
|
1710
|
+
const since = new Date(Date.parse(`${until}T00:00:00Z`) - 30 * 86_400_000).toISOString().slice(0, 10);
|
|
1711
|
+
const through = new Date(Date.parse(`${until}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
|
|
1712
|
+
const gaugeRead = await admin.readTenantStorageGauge(actor, tenantId, 500);
|
|
1713
|
+
const gauge = gaugeRead.gauge;
|
|
1714
|
+
const stale = gauge?.oldestReadAt ? Date.parse(readAt) - Date.parse(gauge.oldestReadAt) > 2 * 86_400_000 : false;
|
|
1715
|
+
const storageState = gaugeRead.truncated ? 'truncated' : !gauge ? 'unavailable' : gauge.sampled === 0 ? (gauge.total === 0 ? 'empty' : 'not-sampled')
|
|
1716
|
+
: gauge.failing > 0 ? 'failing' : stale ? 'stale' : gauge.sampled < gauge.total ? 'partial' : 'complete';
|
|
1717
|
+
// Literal output allowlist: never spread a meter row (which also carries plan data).
|
|
1718
|
+
const storage = {
|
|
1719
|
+
state: storageState,
|
|
1720
|
+
bytes: gauge && gauge.sampled > 0 ? gauge.bytes : null,
|
|
1721
|
+
estimated: false,
|
|
1722
|
+
lowerBound: gaugeRead.truncated,
|
|
1723
|
+
truncatedReason: gaugeRead.truncated ? 'scope-cap' : null,
|
|
1724
|
+
basis: 'scope-databases',
|
|
1725
|
+
sampled: gauge?.sampled ?? 0,
|
|
1726
|
+
total: gauge?.total ?? 0,
|
|
1727
|
+
failing: gauge?.failing ?? 0,
|
|
1728
|
+
oldestReadAt: gauge?.oldestReadAt ?? null,
|
|
1729
|
+
excluded: gauge?.excluded ?? [],
|
|
1730
|
+
};
|
|
1731
|
+
let requests = {
|
|
1732
|
+
state: 'unavailable', requests: null, estimated: true, lowerBound: false,
|
|
1733
|
+
coveredDays: 0, totalDays: 30, sinceDay: since, untilDay: through,
|
|
1734
|
+
lastReadAt: null, byVertical: [], breakdownTruncated: false,
|
|
1735
|
+
};
|
|
1736
|
+
if (usageNow() < deadline && admin.listTenantRequestMeter && admin.listRequestMeterDays) {
|
|
1737
|
+
const [rows, days] = await Promise.all([
|
|
1738
|
+
admin.listTenantRequestMeter(actor, tenantId, since, through, REQUEST_ROLLUP_LIMIT_MAX),
|
|
1739
|
+
admin.listRequestMeterDays(actor, since, through),
|
|
1740
|
+
]);
|
|
1741
|
+
const covered = days.filter((d) => d.readAt !== null && !d.saturated);
|
|
1742
|
+
const coveredDaySet = new Set(covered.map((day) => day.day));
|
|
1743
|
+
const clipped = rows.length === REQUEST_ROLLUP_LIMIT_MAX;
|
|
1744
|
+
const groups = new Map();
|
|
1745
|
+
let count = 0;
|
|
1746
|
+
let countedRows = 0;
|
|
1747
|
+
let requestLabelsTruncated = false;
|
|
1748
|
+
for (const row of rows.slice(0, REQUEST_ROLLUP_LIMIT_MAX - 1)) {
|
|
1749
|
+
if (!coveredDaySet.has(row.day))
|
|
1750
|
+
continue;
|
|
1751
|
+
countedRows++;
|
|
1752
|
+
count += row.requests;
|
|
1753
|
+
if (row.labelTruncated)
|
|
1754
|
+
requestLabelsTruncated = true;
|
|
1755
|
+
else
|
|
1756
|
+
groups.set(row.vertical, (groups.get(row.vertical) ?? 0) + row.requests);
|
|
1757
|
+
}
|
|
1758
|
+
const byVertical = [...groups].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''));
|
|
1759
|
+
const incomplete = covered.length < 30 || clipped;
|
|
1760
|
+
requests = {
|
|
1761
|
+
state: covered.length === 0 ? 'unavailable' : clipped ? 'truncated' : countedRows === 0 ? 'empty' : incomplete ? 'partial' : 'complete',
|
|
1762
|
+
requests: countedRows > 0 ? count : null,
|
|
1763
|
+
estimated: true,
|
|
1764
|
+
lowerBound: countedRows > 0 && incomplete,
|
|
1765
|
+
coveredDays: covered.length,
|
|
1766
|
+
totalDays: 30,
|
|
1767
|
+
sinceDay: since,
|
|
1768
|
+
untilDay: through,
|
|
1769
|
+
lastReadAt: covered.reduce((latest, day) => !latest || day.readAt > latest ? day.readAt : latest, null),
|
|
1770
|
+
byVertical: covered.length > 0 ? byVertical.slice(0, 50).map(([vertical, n]) => ({ vertical, requests: n })) : [],
|
|
1771
|
+
breakdownTruncated: covered.length > 0 && (byVertical.length > 50 || requestLabelsTruncated),
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
const groups = new Map();
|
|
1775
|
+
let calls = 0;
|
|
1776
|
+
let reportedCalls = 0;
|
|
1777
|
+
let inputTokens = 0;
|
|
1778
|
+
let outputTokens = 0;
|
|
1779
|
+
let oldestRecordedAt = null;
|
|
1780
|
+
let newestRecordedAt = null;
|
|
1781
|
+
let truncated = false;
|
|
1782
|
+
let truncatedReason = null;
|
|
1783
|
+
let modelSkipped = false;
|
|
1784
|
+
let labelsTruncated = false;
|
|
1785
|
+
const earlier = (a, b) => a === null || b < a ? b : a;
|
|
1786
|
+
const later = (a, b) => a === null || b > a ? b : a;
|
|
1787
|
+
if (typeof admin.listTenantModelUsageMeter === 'function') {
|
|
1788
|
+
let cursor;
|
|
1789
|
+
for (let page = 0; page < 25; page++) {
|
|
1790
|
+
if (usageNow() >= deadline) {
|
|
1791
|
+
if (calls > 0) {
|
|
1792
|
+
truncated = true;
|
|
1793
|
+
truncatedReason = 'time-budget';
|
|
1794
|
+
}
|
|
1795
|
+
else
|
|
1796
|
+
modelSkipped = true;
|
|
1797
|
+
break;
|
|
1798
|
+
}
|
|
1799
|
+
const entries = await admin.listTenantModelUsageMeter(actor, { tenantId, since: `${since}T00:00:00Z`, until: `${until}T00:00:00Z`, limit: 501, ...(cursor ? { cursor } : {}) });
|
|
1800
|
+
for (const entry of entries.slice(0, 500)) {
|
|
1801
|
+
calls++;
|
|
1802
|
+
if (entry.reported) {
|
|
1803
|
+
reportedCalls++;
|
|
1804
|
+
inputTokens += entry.inputTokens;
|
|
1805
|
+
outputTokens += entry.outputTokens;
|
|
1806
|
+
}
|
|
1807
|
+
oldestRecordedAt = earlier(oldestRecordedAt, entry.at);
|
|
1808
|
+
newestRecordedAt = later(newestRecordedAt, entry.at);
|
|
1809
|
+
if (entry.labelTruncated)
|
|
1810
|
+
labelsTruncated = true;
|
|
1811
|
+
else {
|
|
1812
|
+
const key = `${entry.vertical}\u0000${entry.model}`;
|
|
1813
|
+
const row = groups.get(key) ?? { vertical: entry.vertical, model: entry.model, calls: 0, reportedCalls: 0, inputTokens: 0, outputTokens: 0 };
|
|
1814
|
+
row.calls++;
|
|
1815
|
+
if (entry.reported) {
|
|
1816
|
+
row.reportedCalls++;
|
|
1817
|
+
row.inputTokens += entry.inputTokens;
|
|
1818
|
+
row.outputTokens += entry.outputTokens;
|
|
1819
|
+
}
|
|
1820
|
+
groups.set(key, row);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
truncated = entries.length > 500;
|
|
1824
|
+
truncatedReason = truncated ? 'page-cap' : null;
|
|
1825
|
+
if (!truncated)
|
|
1826
|
+
break;
|
|
1827
|
+
cursor = { at: entries[499].at, id: entries[499].id };
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
const modelGroups = [...groups.values()].sort((a, b) => a.vertical.localeCompare(b.vertical) || a.model.localeCompare(b.model));
|
|
1831
|
+
const models = {
|
|
1832
|
+
state: typeof admin.listTenantModelUsageMeter !== 'function' || modelSkipped ? 'unavailable' : calls === 0 ? 'empty' : truncated ? 'truncated' : 'complete',
|
|
1833
|
+
calls: calls > 0 ? calls : null,
|
|
1834
|
+
reportedCalls,
|
|
1835
|
+
inputTokens: reportedCalls > 0 ? inputTokens : null,
|
|
1836
|
+
outputTokens: reportedCalls > 0 ? outputTokens : null,
|
|
1837
|
+
estimated: false,
|
|
1838
|
+
lowerBound: truncated,
|
|
1839
|
+
truncatedReason,
|
|
1840
|
+
since: `${since}T00:00:00Z`,
|
|
1841
|
+
until: `${until}T00:00:00Z`,
|
|
1842
|
+
oldestRecordedAt,
|
|
1843
|
+
newestRecordedAt,
|
|
1844
|
+
byVerticalModel: modelGroups.slice(0, 50).map((row) => ({ vertical: row.vertical, model: row.model, calls: row.calls, reportedCalls: row.reportedCalls, inputTokens: row.reportedCalls > 0 ? row.inputTokens : null, outputTokens: row.reportedCalls > 0 ? row.outputTokens : null })),
|
|
1845
|
+
breakdownTruncated: modelGroups.length > 50 || labelsTruncated,
|
|
1846
|
+
};
|
|
1847
|
+
return c.json({
|
|
1848
|
+
readAt,
|
|
1849
|
+
retention: { storageMonths: STORAGE_GAUGE_RETENTION_MONTHS, modelDays: MODEL_USAGE_RETENTION_DAYS, requestMonths: BILLING_ROLLUP_RETENTION_MONTHS },
|
|
1850
|
+
storage,
|
|
1851
|
+
requests,
|
|
1852
|
+
models,
|
|
1853
|
+
});
|
|
1854
|
+
});
|
|
1682
1855
|
app.put('/tenants/:tenantId/entitlements/:key', async (c) => {
|
|
1683
1856
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1684
1857
|
// The body is the plan half (#33) and optional — a bodyless PUT is the
|
|
@@ -2361,6 +2534,13 @@ export function createControlPlaneApi(options) {
|
|
|
2361
2534
|
if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
|
|
2362
2535
|
return refuseStatus(c, 409, `connection does not hold '${input.permission}' on scope ${scopeId}; grant it through the tenant's authorized connection flow, then bind`);
|
|
2363
2536
|
}
|
|
2537
|
+
// A change the held binding cannot take in place is the operator's to act on, not a failed write.
|
|
2538
|
+
const held = refusesBindingChange(row.provider)
|
|
2539
|
+
? (await inspectorOf(row)?.bindings?.(c.var.host, row))?.find((b) => b.scopeId === scopeId)
|
|
2540
|
+
: undefined;
|
|
2541
|
+
const changeRefused = bindingChangeRefusal(row.provider, held, input);
|
|
2542
|
+
if (changeRefused)
|
|
2543
|
+
return refuseStatus(c, 409, changeRefused);
|
|
2364
2544
|
const operationId = ulid();
|
|
2365
2545
|
const done = await auditedChange({
|
|
2366
2546
|
flow: 'connection-binding', operationId,
|
|
@@ -2400,6 +2580,70 @@ export function createControlPlaneApi(options) {
|
|
|
2400
2580
|
}
|
|
2401
2581
|
return c.json(connectionProbe.parse(await probe(c.var.host, row)));
|
|
2402
2582
|
});
|
|
2583
|
+
/** The addresses a mail connection may send as (#2343) — the send test's picker, whole and uncut. */
|
|
2584
|
+
app.get('/tenants/:tenantId/connections/:id/senders', async (c) => {
|
|
2585
|
+
const row = await inspectableConnection(c);
|
|
2586
|
+
if (!row)
|
|
2587
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
2588
|
+
const sender = options.mailSenders?.[row.provider];
|
|
2589
|
+
if (!sender)
|
|
2590
|
+
return refuseStatus(c, 501, `no mail sender registered for provider '${row.provider}'`);
|
|
2591
|
+
return c.json(connectionSenders.parse({ senders: await sender.senders(c.var.host, row) }));
|
|
2592
|
+
});
|
|
2593
|
+
/**
|
|
2594
|
+
* **Send a test message through a connection** (#2343) — the probe for mail, which cannot be
|
|
2595
|
+
* probed without sending. From one of the connection's own senders, to one recipient; the
|
|
2596
|
+
* dashboard's door sends only to the signed-in person's own address.
|
|
2597
|
+
*
|
|
2598
|
+
* Like `/verify`, a refusal is `200 { ok: false, error }` in the connector's own words (outside
|
|
2599
|
+
* the granted scope, throttled, sign-in failed), and the send rides the connector's recorded
|
|
2600
|
+
* egress, so health is written as for any send. A success is recorded by the connector as that
|
|
2601
|
+
* sender's verification. Not an audited admin-log mutation, the same class as a probe; who
|
|
2602
|
+
* asked is logged on the request record instead, never the recipient.
|
|
2603
|
+
*/
|
|
2604
|
+
app.post('/tenants/:tenantId/connections/:id/send-test', async (c) => {
|
|
2605
|
+
const row = await inspectableConnection(c);
|
|
2606
|
+
if (!row)
|
|
2607
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
2608
|
+
const sender = options.mailSenders?.[row.provider];
|
|
2609
|
+
if (!sender)
|
|
2610
|
+
return refuseStatus(c, 501, `no mail sender registered for provider '${row.provider}'`);
|
|
2611
|
+
const body = connectionSendTest.safeParse(await c.req.json().catch(() => null));
|
|
2612
|
+
if (!body.success)
|
|
2613
|
+
return refuse(c, 'validation_failed', 'from and to must each be one email address');
|
|
2614
|
+
const { from, to } = body.data;
|
|
2615
|
+
const senders = await sender.senders(c.var.host, row);
|
|
2616
|
+
if (!senders.some((s) => s.toLowerCase() === from.toLowerCase())) {
|
|
2617
|
+
return refuse(c, 'validation_failed', `'${from}' is not one of this connection's sender addresses`);
|
|
2618
|
+
}
|
|
2619
|
+
const principal = c.get('principal');
|
|
2620
|
+
console.log('connection.send-test', {
|
|
2621
|
+
tenantId: row.tenantId,
|
|
2622
|
+
connectionId: row.id,
|
|
2623
|
+
provider: row.provider,
|
|
2624
|
+
actor: c.get('actor'),
|
|
2625
|
+
onBehalfOf: principal.kind === 'tenant' ? (principal.onBehalfOf?.principal ?? null) : null,
|
|
2626
|
+
});
|
|
2627
|
+
try {
|
|
2628
|
+
await sender.send(c.var.host, row, {
|
|
2629
|
+
from: { email: from },
|
|
2630
|
+
to: [{ email: to }],
|
|
2631
|
+
subject: 'Substrat test message',
|
|
2632
|
+
html: `<p>${SEND_TEST_TEXT}</p>`,
|
|
2633
|
+
text: SEND_TEST_TEXT,
|
|
2634
|
+
attachments: [],
|
|
2635
|
+
});
|
|
2636
|
+
return c.json(connectionSendTestResult.parse({ ok: true, status: null, error: null }));
|
|
2637
|
+
}
|
|
2638
|
+
catch (e) {
|
|
2639
|
+
const status = e?.status;
|
|
2640
|
+
return c.json(connectionSendTestResult.parse({
|
|
2641
|
+
ok: false,
|
|
2642
|
+
status: typeof status === 'number' ? status : null,
|
|
2643
|
+
error: (e instanceof Error ? e.message : String(e)).slice(0, 600),
|
|
2644
|
+
}));
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2403
2647
|
/**
|
|
2404
2648
|
* **What the connection has done** (#605) — the connector's dispatch ledger, projected.
|
|
2405
2649
|
*
|
|
@@ -7410,7 +7654,13 @@ export function createControlPlaneApi(options) {
|
|
|
7410
7654
|
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
|
|
7411
7655
|
}
|
|
7412
7656
|
}
|
|
7413
|
-
|
|
7657
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
7658
|
+
if (!registration)
|
|
7659
|
+
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
7660
|
+
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
7661
|
+
// before this read is seen, and none is written until this upload is done.
|
|
7662
|
+
const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
|
|
7663
|
+
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7414
7664
|
}
|
|
7415
7665
|
}
|
|
7416
7666
|
catch (e) {
|
|
@@ -7982,12 +8232,98 @@ export function createControlPlaneApi(options) {
|
|
|
7982
8232
|
// pointing at a deployment that is not there. The version lands PENDING — except a
|
|
7983
8233
|
// PRIVATE vertical's, which self-admits below the seam (its blast radius is its own
|
|
7984
8234
|
// tenant); for everything else admission still gates serving.
|
|
8235
|
+
app.all('/projects', async (c) => {
|
|
8236
|
+
if (!options.sourceBuilds)
|
|
8237
|
+
return refuseStatus(c, 501, 'source builds are not configured');
|
|
8238
|
+
const p = c.get('principal');
|
|
8239
|
+
if (p.kind !== 'builder' && p.kind !== 'staff')
|
|
8240
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8241
|
+
const tenant = p.kind === 'builder' ? p.tenantId : c.req.query('tenant');
|
|
8242
|
+
if (!tenant)
|
|
8243
|
+
return refuse(c, 'validation_failed', 'tenant id is required');
|
|
8244
|
+
const tenants = await c.var.admin.listTenants(c.get('actor'));
|
|
8245
|
+
if (!tenants.some((t) => t.id === tenant))
|
|
8246
|
+
return refuse(c, 'not_found', 'unknown workspace');
|
|
8247
|
+
let body;
|
|
8248
|
+
if (c.req.method === 'POST') {
|
|
8249
|
+
const input = z.object({ slug: z.string().min(1), name: z.string().min(1), repository: z.unknown().optional() }).parse(await c.req.json());
|
|
8250
|
+
const team = tenants.find((t) => t.id === tenant);
|
|
8251
|
+
const slug = await ownerOf(c.get('actor'), input.slug) === tenant || input.slug.startsWith(`${team.slug}/`) ? input.slug : `${team.slug}/${input.slug}`;
|
|
8252
|
+
verticalSlug.parse(slug);
|
|
8253
|
+
const owner = await ownerOf(c.get('actor'), slug);
|
|
8254
|
+
if (owner !== undefined && owner !== tenant)
|
|
8255
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8256
|
+
body = JSON.stringify({ ...input, slug });
|
|
8257
|
+
}
|
|
8258
|
+
else if (c.req.method !== 'GET')
|
|
8259
|
+
return refuseStatus(c, 405, 'method not allowed');
|
|
8260
|
+
return options.sourceBuilds.projects(tenant, new Request('https://source/projects', {
|
|
8261
|
+
method: c.req.method, ...(body ? { headers: { 'content-type': 'application/json' }, body } : {}),
|
|
8262
|
+
}));
|
|
8263
|
+
});
|
|
8264
|
+
app.post('/source/github-access', async (c) => {
|
|
8265
|
+
if (c.get('principal').kind !== 'staff')
|
|
8266
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8267
|
+
if (!options.githubRepositoryAccess)
|
|
8268
|
+
return refuseStatus(c, 501, 'GitHub source access is not configured');
|
|
8269
|
+
const input = z.object({ tenantId: tenantIdSchema, owner: z.string().regex(/^[\w-]+$/), repo: z.string().regex(/^[\w.-]+$/), scope: z.enum(['read', 'write']).default('read') }).parse(await c.req.json());
|
|
8270
|
+
const connection = await c.var.admin.openConnection(input.tenantId, 'dashboard', 'github', input.owner);
|
|
8271
|
+
const installationId = connection?.secret.installationId;
|
|
8272
|
+
if (typeof installationId !== 'string')
|
|
8273
|
+
return refuse(c, 'not_found', 'no connected GitHub installation for this tenant and owner');
|
|
8274
|
+
return c.json(await options.githubRepositoryAccess(input.tenantId, input.owner, input.repo, installationId, input.scope));
|
|
8275
|
+
});
|
|
7985
8276
|
app.post('/verticals/:slug/deploy', async (c) => {
|
|
7986
8277
|
if (!options.deployVertical) {
|
|
7987
8278
|
return refuseStatus(c, 501, 'deploy is not configured on this control plane');
|
|
7988
8279
|
}
|
|
7989
8280
|
const p = c.get('principal');
|
|
7990
|
-
|
|
8281
|
+
let built;
|
|
8282
|
+
let requestedVersion;
|
|
8283
|
+
let form;
|
|
8284
|
+
if (options.sourceBuilds) {
|
|
8285
|
+
if (!c.req.header('content-type')?.startsWith('application/json')) {
|
|
8286
|
+
return refuseStatus(c, 415, 'submit source JSON; client-built module uploads are no longer accepted');
|
|
8287
|
+
}
|
|
8288
|
+
const input = sourceBuildRequest.parse(await c.req.json());
|
|
8289
|
+
const bare = c.req.param('slug');
|
|
8290
|
+
let sourceSlug = bare;
|
|
8291
|
+
let sourceTenant = null;
|
|
8292
|
+
if (p.kind === 'builder') {
|
|
8293
|
+
if (input.tenant && input.tenant !== p.tenantId && input.tenant !== p.tenantSlug) {
|
|
8294
|
+
return refuse(c, 'forbidden', `push is pinned to workspace '${input.tenant}' but this session acts for '${p.tenantSlug}'`);
|
|
8295
|
+
}
|
|
8296
|
+
sourceTenant = p.tenantId;
|
|
8297
|
+
sourceSlug = effectiveSlug(p, bare);
|
|
8298
|
+
}
|
|
8299
|
+
else if (input.tenant) {
|
|
8300
|
+
const tenant = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.id === input.tenant || t.slug === input.tenant);
|
|
8301
|
+
if (!tenant)
|
|
8302
|
+
return refuse(c, 'not_found', 'unknown workspace');
|
|
8303
|
+
sourceTenant = tenant.id;
|
|
8304
|
+
sourceSlug = bare.startsWith(`${tenant.slug}/`) || await ownerOf(c.get('actor'), bare) === tenant.id ? bare : `${tenant.slug}/${bare}`;
|
|
8305
|
+
}
|
|
8306
|
+
const owner = await ownerOf(c.get('actor'), sourceSlug);
|
|
8307
|
+
if (sourceTenant && owner !== undefined && owner !== sourceTenant)
|
|
8308
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8309
|
+
sourceTenant ??= owner ?? null;
|
|
8310
|
+
verticalSlug.parse(sourceSlug);
|
|
8311
|
+
if (platformScriptCollision(sourceSlug))
|
|
8312
|
+
return refuseStatus(c, 422, 'reserved platform script name');
|
|
8313
|
+
requestedVersion = input.version;
|
|
8314
|
+
built = await options.sourceBuilds.build(sourceTenant ?? 'platform', sourceSlug, input);
|
|
8315
|
+
form = built.form;
|
|
8316
|
+
// Addressing is the authenticated caller's, never a field emitted by project code.
|
|
8317
|
+
form.delete('tenant');
|
|
8318
|
+
if (input.tenant)
|
|
8319
|
+
form.set('tenant', input.tenant);
|
|
8320
|
+
form.delete('allowFork');
|
|
8321
|
+
if (input.allowFork)
|
|
8322
|
+
form.set('allowFork', '1');
|
|
8323
|
+
}
|
|
8324
|
+
else {
|
|
8325
|
+
return refuseStatus(c, 501, 'platform source builds are not configured');
|
|
8326
|
+
}
|
|
7991
8327
|
const raw = form.get('manifest');
|
|
7992
8328
|
if (typeof raw !== 'string')
|
|
7993
8329
|
return refuse(c, 'validation_failed', 'missing manifest part');
|
|
@@ -7996,6 +8332,8 @@ export function createControlPlaneApi(options) {
|
|
|
7996
8332
|
return refuse(c, 'validation_failed', 'manifest registry: required when modules are present');
|
|
7997
8333
|
}
|
|
7998
8334
|
const manifest = deployManifest.parse(parsed);
|
|
8335
|
+
if (requestedVersion && manifest.version !== requestedVersion)
|
|
8336
|
+
return refuseStatus(c, 502, 'build service produced a different release version');
|
|
7999
8337
|
const moduleProblems = pushedModuleRegistryProblems(manifest.modules, manifest.registry);
|
|
8000
8338
|
if (moduleProblems.length > 0)
|
|
8001
8339
|
return refuse(c, 'validation_failed', `manifest registry.permissions: ${moduleProblems.join('; ')}`);
|
|
@@ -8067,7 +8405,7 @@ export function createControlPlaneApi(options) {
|
|
|
8067
8405
|
// Otherwise the claim lands under the tenant prefix, exactly like a builder push —
|
|
8068
8406
|
// same namespace, no claim race with other workspaces' bare names.
|
|
8069
8407
|
const bareOwner = await ownerOf(c.get('actor'), bare);
|
|
8070
|
-
slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
8408
|
+
slug = bare.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
8071
8409
|
const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
|
|
8072
8410
|
if (existingOwner !== undefined && existingOwner !== workspace.id) {
|
|
8073
8411
|
return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
|
|
@@ -8345,7 +8683,7 @@ export function createControlPlaneApi(options) {
|
|
|
8345
8683
|
}
|
|
8346
8684
|
}
|
|
8347
8685
|
const version = await c.var.admin.getVersion(c.get('actor'), id, slug);
|
|
8348
|
-
return c.json({ ...version, ...(warnings.length ? { warnings } : {}) }, 201);
|
|
8686
|
+
return c.json({ ...version, ...(built ? { projectId: built.projectId, revision: built.revision, dataLoss: built.dataLoss } : {}), ...(warnings.length ? { warnings } : {}) }, 201);
|
|
8349
8687
|
});
|
|
8350
8688
|
// -- where this platform runs (ops ergonomics) -----------------------------
|
|
8351
8689
|
// The console renders refs it already has — `servingRef`, a store's `ref`, a version's
|
|
@@ -10641,10 +10979,7 @@ export function createControlPlaneApi(options) {
|
|
|
10641
10979
|
* `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
|
|
10642
10980
|
* browser marks as from another origin (403). A request with neither header is the CLI's.
|
|
10643
10981
|
*/
|
|
10644
|
-
const
|
|
10645
|
-
if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
|
|
10646
|
-
throw new ControlPlaneError(415, `${words.write} takes application/json`);
|
|
10647
|
-
}
|
|
10982
|
+
const staffPerson = async (c, words) => {
|
|
10648
10983
|
const origin = c.req.header('origin');
|
|
10649
10984
|
const site = c.req.header('sec-fetch-site');
|
|
10650
10985
|
if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
|
|
@@ -10657,20 +10992,87 @@ export function createControlPlaneApi(options) {
|
|
|
10657
10992
|
}
|
|
10658
10993
|
return actor;
|
|
10659
10994
|
};
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
const pin = registration.releases.find((p) => p.sha256 === sha256);
|
|
10666
|
-
if (!pin) {
|
|
10667
|
-
throw new ControlPlaneError(422, `release ${sha256} of '${registration.driver}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
|
|
10668
|
-
}
|
|
10669
|
-
if (pin.sourceCommit !== stored.sourceCommit) {
|
|
10670
|
-
throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
|
|
10671
|
-
}
|
|
10672
|
-
return pin;
|
|
10995
|
+
const byAPerson = async (c, words = { write: 'a store driver release write', person: 'a store driver release is published by a person' }) => {
|
|
10996
|
+
if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
|
|
10997
|
+
throw new ControlPlaneError(415, `${words.write} takes application/json`);
|
|
10998
|
+
}
|
|
10999
|
+
return staffPerson(c, words);
|
|
10673
11000
|
};
|
|
11001
|
+
const approvalWords = { write: 'a store driver release approval', person: 'a store driver release is approved by a person' };
|
|
11002
|
+
const revokeWords = { write: 'a store driver release revoke', person: 'a store driver release is revoked by a person' };
|
|
11003
|
+
const statusWords = { write: 'store driver release status', person: 'store driver release status is read by a person' };
|
|
11004
|
+
const nonPrinting = /[\p{Cc}\p{Cf}\u2028\u2029]/u;
|
|
11005
|
+
const auditText = z.string().min(1).max(2048).refine((value) => value.trim().length > 0 && !nonPrinting.test(value), { message: 'must be printable text' });
|
|
11006
|
+
const reviewEvidence = z.object({ met: z.boolean(), evidence: auditText }).strict();
|
|
11007
|
+
const checkedEvidence = z.object({ checked: z.literal(true), evidence: auditText }).strict();
|
|
11008
|
+
// D-66 stores the reviewer's exact references: one HTTPS repository URL and one hex revision.
|
|
11009
|
+
const repositoryReference = z.string().min(1).max(1024).refine((value) => {
|
|
11010
|
+
if (!value.startsWith('https://') || /[\s\\?#]/u.test(value) || nonPrinting.test(value))
|
|
11011
|
+
return false;
|
|
11012
|
+
try {
|
|
11013
|
+
const url = new URL(value);
|
|
11014
|
+
return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password &&
|
|
11015
|
+
!url.search && !url.hash && url.pathname.split('/').some(Boolean);
|
|
11016
|
+
}
|
|
11017
|
+
catch {
|
|
11018
|
+
return false;
|
|
11019
|
+
}
|
|
11020
|
+
}, { message: 'a source repository is an HTTPS URL with a host and path' });
|
|
11021
|
+
const sourceCommit = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, { message: 'a source commit is 40 or 64 lowercase hex characters' });
|
|
11022
|
+
// The release entry loads a Python class by identifier; catalog names keep their existing
|
|
11023
|
+
// fixed-label grammar and additionally exclude characters that cannot safely appear in audit.
|
|
11024
|
+
const approvalClassName = z.string().min(1).max(256).regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
|
|
11025
|
+
const approvalOperation = storeCatalogOperation.refine((name) => !nonPrinting.test(name));
|
|
11026
|
+
const approvalBody = z.object({
|
|
11027
|
+
reason: auditText,
|
|
11028
|
+
sourceReferences: z.array(z.object({ repository: repositoryReference, commit: sourceCommit }).strict()).min(1).max(64),
|
|
11029
|
+
isolationReview: z.object({
|
|
11030
|
+
separateScriptPerTenant: reviewEvidence,
|
|
11031
|
+
noOutboundAccess: reviewEvidence,
|
|
11032
|
+
envelopeVerifiedOutsideDriver: reviewEvidence,
|
|
11033
|
+
envelopeChecked: checkedEvidence,
|
|
11034
|
+
reservedOperationsChecked: checkedEvidence,
|
|
11035
|
+
noModuleStateChecked: checkedEvidence,
|
|
11036
|
+
}).strict(),
|
|
11037
|
+
schema: z.number().int().min(0),
|
|
11038
|
+
className: approvalClassName,
|
|
11039
|
+
operations: z.array(approvalOperation).max(256),
|
|
11040
|
+
}).strict();
|
|
11041
|
+
const revokeBody = z.object({ reason: auditText, emergency: z.boolean().optional() }).strict();
|
|
11042
|
+
const approvalPathParts = new Set([
|
|
11043
|
+
'reason', 'sourceReferences', 'repository', 'commit', 'isolationReview', 'separateScriptPerTenant',
|
|
11044
|
+
'noOutboundAccess', 'envelopeVerifiedOutsideDriver', 'envelopeChecked', 'reservedOperationsChecked',
|
|
11045
|
+
'noModuleStateChecked', 'met', 'checked', 'evidence', 'schema', 'className', 'operations', 'emergency',
|
|
11046
|
+
]);
|
|
11047
|
+
const issueCodes = {
|
|
11048
|
+
unrecognized_keys: 'unrecognized_key', invalid_type: 'invalid_type', too_small: 'out_of_range',
|
|
11049
|
+
too_big: 'out_of_range', invalid_format: 'invalid_format', custom: 'invalid_value',
|
|
11050
|
+
};
|
|
11051
|
+
const safeIssue = (issue) => {
|
|
11052
|
+
const knownPath = issue.path.every((part) => (typeof part === 'number' && Number.isSafeInteger(part) && part >= 0) ||
|
|
11053
|
+
(typeof part === 'string' && approvalPathParts.has(part)));
|
|
11054
|
+
const path = knownPath && issue.path.length ? issue.path.join('.') : '(body)';
|
|
11055
|
+
return `${path}: ${issueCodes[issue.code] ?? 'invalid_value'}`;
|
|
11056
|
+
};
|
|
11057
|
+
const releaseSha = (value) => {
|
|
11058
|
+
if (!/^[0-9a-f]{64}$/.test(value))
|
|
11059
|
+
throw new ControlPlaneError(422, 'a release sha256 is 64 lowercase hex characters');
|
|
11060
|
+
return value;
|
|
11061
|
+
};
|
|
11062
|
+
const parseApprovalBody = (raw) => {
|
|
11063
|
+
const parsed = approvalBody.safeParse(raw);
|
|
11064
|
+
if (!parsed.success)
|
|
11065
|
+
throw new ControlPlaneError(422, `not a store driver approval: ${parsed.error.issues.map(safeIssue).join('; ')}`);
|
|
11066
|
+
return parsed.data;
|
|
11067
|
+
};
|
|
11068
|
+
const parseRevokeBody = (raw) => {
|
|
11069
|
+
const parsed = revokeBody.safeParse(raw);
|
|
11070
|
+
if (!parsed.success)
|
|
11071
|
+
throw new ControlPlaneError(422, `not a store driver revoke: ${parsed.error.issues.map(safeIssue).join('; ')}`);
|
|
11072
|
+
return parsed.data;
|
|
11073
|
+
};
|
|
11074
|
+
// Whether the release is stored, approved and keeps the schema is judged inside
|
|
11075
|
+
// `runStoreDriverRollout`, under its exclusive hold (D-65), never here before it.
|
|
10674
11076
|
const rollOut = async (c, registration, sha256, action) => {
|
|
10675
11077
|
const { flow, ensureScopeStores } = releaseFlow();
|
|
10676
11078
|
const actor = c.get('actor');
|
|
@@ -10685,22 +11087,11 @@ export function createControlPlaneApi(options) {
|
|
|
10685
11087
|
const result = await ensureScopeStores({ ...input, loadRelease });
|
|
10686
11088
|
return result ? result.uploaded > 0 : true;
|
|
10687
11089
|
},
|
|
10688
|
-
ensureCanary: (to) => flow.ensureCanary({ driver: registration.driver, to, loadRelease }),
|
|
11090
|
+
ensureCanary: (to, approvals) => flow.ensureCanary({ driver: registration.driver, to, loadRelease, approvals }),
|
|
10689
11091
|
callCanary: flow.callCanary,
|
|
10690
11092
|
});
|
|
10691
11093
|
return c.json(result, result.complete ? 200 : 202);
|
|
10692
11094
|
};
|
|
10693
|
-
/** Neither a rollout nor a rollback lowers the stored schema: a driver migrates forward only. */
|
|
10694
|
-
const assertSchemaKept = (registration, from, to) => {
|
|
10695
|
-
if (from === null || from === to.sha256)
|
|
10696
|
-
return;
|
|
10697
|
-
const leaving = registration.releases.find((p) => p.sha256 === from);
|
|
10698
|
-
if (!leaving)
|
|
10699
|
-
throw new ControlPlaneError(409, `the current release ${from} of '${registration.driver}' is not pinned, so its schema cannot be judged`);
|
|
10700
|
-
if (to.schema < leaving.schema) {
|
|
10701
|
-
throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
|
|
10702
|
-
}
|
|
10703
|
-
};
|
|
10704
11095
|
/** Where the platform's rebuild of every script of the driver stands (#2309), if it rebuilds them. */
|
|
10705
11096
|
const rebuildView = async (admin, actor, driver, known) => {
|
|
10706
11097
|
const fingerprint = known ?? (await options.storeReleases?.fingerprint?.());
|
|
@@ -10711,29 +11102,87 @@ export function createControlPlaneApi(options) {
|
|
|
10711
11102
|
return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
|
|
10712
11103
|
};
|
|
10713
11104
|
app.get('/store-drivers/:driver', async (c) => {
|
|
11105
|
+
await staffPerson(c, statusWords);
|
|
10714
11106
|
const registration = registeredDriver(c.req.param('driver'));
|
|
10715
|
-
const state = await
|
|
11107
|
+
const [state, approvals] = await Promise.all([
|
|
11108
|
+
c.var.admin.storeDriverState(c.get('actor'), registration.driver),
|
|
11109
|
+
readStoreDriverApprovals(c.var.admin, c.get('actor'), registration),
|
|
11110
|
+
]);
|
|
10716
11111
|
const rebuild = await rebuildView(c.var.admin, c.get('actor'), registration.driver);
|
|
10717
|
-
|
|
11112
|
+
// `pins` stays for released CLIs (RFC §5 step 3): the releases a move may go to now, one per live approval.
|
|
11113
|
+
const pins = approvals.records
|
|
11114
|
+
.filter((a) => liveApprovalOf(approvals, a.sha256) === a)
|
|
11115
|
+
.map((a) => ({ sha256: a.sha256, sourceCommit: a.sourceCommit, schema: a.schema }));
|
|
11116
|
+
return c.json({ driver: registration.driver, pins, approvals: approvals.records, ...state, ...(rebuild ? { rebuild } : {}) });
|
|
10718
11117
|
});
|
|
10719
|
-
// Store a release's bytes, before or after
|
|
11118
|
+
// Store a release's bytes, before or after a person approves them (D-65). Idempotent per bundle.
|
|
10720
11119
|
app.put('/store-drivers/:driver/releases', async (c) => {
|
|
10721
11120
|
const registration = registeredDriver(c.req.param('driver'));
|
|
10722
11121
|
const actor = await byAPerson(c);
|
|
10723
11122
|
const { flow } = releaseFlow();
|
|
10724
11123
|
const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
|
|
10725
11124
|
const release = await storeDriverRelease({ admin: c.var.admin, actor, bytes: flow.bytes, driver: registration.driver, body });
|
|
10726
|
-
|
|
11125
|
+
const live = liveApprovalOf(await readStoreDriverApprovals(c.var.admin, actor, registration), release.sha256);
|
|
11126
|
+
const approved = live !== undefined && live.sourceCommit === release.sourceCommit;
|
|
11127
|
+
// `pinned` stays beside `approved` for released CLIs, meaning the same (RFC §5 step 3).
|
|
11128
|
+
return c.json({ release, pinned: approved, approved }, 201);
|
|
11129
|
+
});
|
|
11130
|
+
// The approval is a second, person-only write over bytes the platform already named by digest.
|
|
11131
|
+
app.post('/store-drivers/:driver/releases/:sha256/approvals', async (c) => {
|
|
11132
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
11133
|
+
const actor = await byAPerson(c, approvalWords);
|
|
11134
|
+
const sha256 = releaseSha(c.req.param('sha256'));
|
|
11135
|
+
const body = parseApprovalBody(await readCappedJson(c.req.raw, 64 * 1024));
|
|
11136
|
+
// Refused here, not only by the readers (D-65 §3): a row its basis does not admit could never
|
|
11137
|
+
// run, yet as a live approval it would hold the one slot a corrected approval needs. Fixed text,
|
|
11138
|
+
// no input echoed.
|
|
11139
|
+
if (!storeDriverBasisAdmits(registration.isolationBasis, body.sourceReferences)) {
|
|
11140
|
+
throw new ControlPlaneError(422, 'not a store driver approval: sourceReferences: outside_isolation_basis');
|
|
11141
|
+
}
|
|
11142
|
+
const flow = options.storeReleases;
|
|
11143
|
+
if (!flow)
|
|
11144
|
+
throw new ControlPlaneError(501, 'this control plane cannot read stored release bytes for approval');
|
|
11145
|
+
const approval = await approveStoredDriverRelease({ admin: c.var.admin, actor, bytes: flow.bytes, driver: registration.driver, sha256, approval: body });
|
|
11146
|
+
const state = await c.var.admin.storeDriverState(actor, registration.driver);
|
|
11147
|
+
const approvals = state.current ? await c.var.admin.storeDriverApprovals(actor, registration.driver) : [];
|
|
11148
|
+
// The current release's approval facts, revoked or not: they are fixed at its first approval.
|
|
11149
|
+
const currentSchema = state.current ? approvals.find((a) => a.sha256 === state.current.sha256)?.schema ?? null : null;
|
|
11150
|
+
return c.json({ approval, currentSchema }, 201);
|
|
11151
|
+
});
|
|
11152
|
+
app.post('/store-drivers/:driver/releases/:sha256/revoke', async (c) => {
|
|
11153
|
+
const registration = registeredDriver(c.req.param('driver'));
|
|
11154
|
+
const actor = await byAPerson(c, revokeWords);
|
|
11155
|
+
const sha256 = releaseSha(c.req.param('sha256'));
|
|
11156
|
+
const body = parseRevokeBody(await readCappedJson(c.req.raw, 8 * 1024));
|
|
11157
|
+
const flow = options.storeReleases;
|
|
11158
|
+
if (!body.emergency && !flow?.readScriptRelease) {
|
|
11159
|
+
throw new ControlPlaneError(501, 'this control plane cannot read store script releases for a guarded revoke');
|
|
11160
|
+
}
|
|
11161
|
+
const result = await runStoreDriverRevoke({
|
|
11162
|
+
admin: c.var.admin,
|
|
11163
|
+
actor,
|
|
11164
|
+
driver: registration.driver,
|
|
11165
|
+
sha256,
|
|
11166
|
+
reason: body.reason,
|
|
11167
|
+
emergency: body.emergency === true,
|
|
11168
|
+
readScriptRelease: flow?.readScriptRelease ?? (async () => ({ exists: false })),
|
|
11169
|
+
ensureCanary: async (to) => {
|
|
11170
|
+
if (!flow)
|
|
11171
|
+
throw new ControlPlaneError(501, 'this control plane cannot move the store canary');
|
|
11172
|
+
const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
|
|
11173
|
+
// Called under the revoke's exclusive hold, so the approvals read here are the ones it judges.
|
|
11174
|
+
const approvals = await readStoreDriverApprovals(c.var.admin, actor, registration);
|
|
11175
|
+
await flow.ensureCanary({ driver: registration.driver, to, loadRelease, approvals });
|
|
11176
|
+
},
|
|
11177
|
+
});
|
|
11178
|
+
return c.json(result, result.complete ? 200 : 202);
|
|
10727
11179
|
});
|
|
10728
|
-
// Roll a stored,
|
|
11180
|
+
// Roll a stored, approved release out: the canary, then every vertical that ever declared the
|
|
10729
11181
|
// driver. Posting the same release again converges a rollout that stopped.
|
|
10730
11182
|
app.post('/store-drivers/:driver/rollouts', async (c) => {
|
|
10731
11183
|
const registration = registeredDriver(c.req.param('driver'));
|
|
10732
11184
|
await byAPerson(c);
|
|
10733
11185
|
const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
|
|
10734
|
-
const pin = await pinOf(c, registration, sha256);
|
|
10735
|
-
const { current } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
|
|
10736
|
-
assertSchemaKept(registration, current?.sha256 ?? null, pin);
|
|
10737
11186
|
return rollOut(c, registration, sha256, 'rollout');
|
|
10738
11187
|
});
|
|
10739
11188
|
// Return to the immediately previous good release (D-63), never further and never a guess. After
|
|
@@ -10746,8 +11195,6 @@ export function createControlPlaneApi(options) {
|
|
|
10746
11195
|
const { current, rollbackTarget } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
|
|
10747
11196
|
if (!current || !rollbackTarget)
|
|
10748
11197
|
throw new ControlPlaneError(409, `driver '${registration.driver}' has no previous good release to roll back to`);
|
|
10749
|
-
const pin = await pinOf(c, registration, rollbackTarget);
|
|
10750
|
-
assertSchemaKept(registration, current.sha256, pin);
|
|
10751
11198
|
return rollOut(c, registration, rollbackTarget, 'rollback');
|
|
10752
11199
|
});
|
|
10753
11200
|
// Run a pass of the platform's rebuild of every script of the driver now (#2309): the scheduled
|
|
@@ -10937,7 +11384,7 @@ const WIRE_WALK_REQUEST_MAX_BYTES = 64 * 1024;
|
|
|
10937
11384
|
* The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
|
|
10938
11385
|
* for the JSON around it. A bigger body is refused (413) before it is read whole.
|
|
10939
11386
|
*/
|
|
10940
|
-
const STORE_RELEASE_REQUEST_MAX_BYTES =
|
|
11387
|
+
const STORE_RELEASE_REQUEST_MAX_BYTES = STORE_RELEASE_STORED_MAX_BYTES;
|
|
10941
11388
|
/** A JSON body read to at most `max` bytes: a declared length over it, or a stream past it, is 413. */
|
|
10942
11389
|
async function readCappedJson(request, max) {
|
|
10943
11390
|
const declared = Number(request.headers.get('content-length') ?? NaN);
|