@substrat-run/adapter-sqlite 0.160.0 → 0.161.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/index.d.ts +46 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +532 -258
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, assertNoBindingShapeBreak, importsOfManifestJson, connectionLandingsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, identityLink, identityPool, instant, isSubstratError, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, principalBecomeCapabilityInput, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, deploymentWireAuth, callsOfManifestJson, outboundOfManifestJson, listLimitOf, pageOf, countedPageOf, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, redrainEventsInput, REDRAIN_BATCH, errorCodeOf, } from '@substrat-run/contracts';
|
|
6
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, redeemOrReplayUploadLink, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, assertPlatformRequestRoom, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, scopeNotHeldRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, registerTrashTargets, runPurgePass, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, isSizedByteStream, knownLengthStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, ATTACHMENT_BYTES_SQL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeAttachmentStorageRows, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink, listConnectLinks as listConnectLinkRows, readConnectLink, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleRowsErased, moduleErasurePlan, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleDefinitionRefusal, reservedRoleRefusal, reservedShapeRefusal, SCOPE_SEATED_ROLES_SQL, SEAT_HOLDING_SCOPE_STATUSES, TENANT_SEATED_ROLES_SQL, admitPeer, boundToPeerDeclarations, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, executableSqlStatements, blankSqlComments, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, DEPLOYMENT_WIRE_AUTH_DDL, assertWireAuthFilter, deploymentWireAuthOf, forgetVerticalWireAuth, wireAuthAccessTarget, writeDeploymentWireAuth, indexVersionScopeStores, driverDeclarationsOf, verticalScopeStoresOf, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, deriveVersionMigrationDigest, publishedMigrationDigest, migrationChangeRefusal, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, fenceAttachmentSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, sweepOrphanAttachments, missingAttachmentStore, SETTLED_COPY_STATES, unsettledCopyRefusal, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, fetchLikeOf, deadlineFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf, } from '@substrat-run/kernel';
|
|
6
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, redeemOrReplayUploadLink, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, assertPlatformRequestRoom, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, scopeNotHeldRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, registerTrashTargets, runPurgePass, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, isSizedByteStream, knownLengthStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, ATTACHMENT_BYTES_SQL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeAttachmentStorageRows, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink, listConnectLinks as listConnectLinkRows, readConnectLink, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleRowsErased, moduleErasurePlan, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, dumpRoleRefusal, reservedGrantRefusal, reservedRoleDefinitionRefusal, reservedRoleRefusal, reservedShapeRefusal, SCOPE_SEATED_ROLES_SQL, SEAT_HOLDING_SCOPE_STATUSES, TENANT_SEATED_ROLES_SQL, admitPeer, boundToPeerDeclarations, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, executableSqlStatements, blankSqlComments, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, DEPLOYMENT_WIRE_AUTH_DDL, assertWireAuthFilter, deploymentWireAuthOf, forgetVerticalWireAuth, wireAuthAccessTarget, writeDeploymentWireAuth, indexVersionScopeStores, driverDeclarationsOf, verticalScopeStoresOf, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, deriveVersionMigrationDigest, publishedMigrationDigest, migrationChangeRefusal, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, fenceAttachmentSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, sweepOrphanAttachments, missingAttachmentStore, SETTLED_COPY_STATES, unsettledCopyRefusal, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, fetchLikeOf, deadlineFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf, } from '@substrat-run/kernel';
|
|
7
7
|
import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
|
|
8
8
|
import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
|
|
9
9
|
import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
|
|
@@ -69,6 +69,62 @@ async function writeStreamToFile(path, body) {
|
|
|
69
69
|
closeSync(fd);
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* A dropped stream's file handle, closed when the stream is collected (#2514): a consumer that neither
|
|
74
|
+
* reads a stream to its end nor cancels it would otherwise hold the handle for the life of the process.
|
|
75
|
+
* Best effort, since collection has no deadline; reading or cancelling is what closes it promptly.
|
|
76
|
+
*/
|
|
77
|
+
const unreadFiles = new FinalizationRegistry((handle) => handle.close());
|
|
78
|
+
/**
|
|
79
|
+
* The file at `path` as a stream read 64 KiB at a time as it is pulled (#2514), with its size, or
|
|
80
|
+
* `null` when there is no such file. The file is opened here, so a key removed after the open is still
|
|
81
|
+
* read whole from the open handle, and closed once the stream ends, errors or is cancelled, or (best
|
|
82
|
+
* effort) once a stream nobody finished is collected.
|
|
83
|
+
*/
|
|
84
|
+
function fileStream(path) {
|
|
85
|
+
const CHUNK = 64 * 1024;
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
fd = openSync(path, 'r');
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// Removed between the read gate and here: the same answer as a key that was never there.
|
|
92
|
+
if (err.code === 'ENOENT')
|
|
93
|
+
return null;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const size = fstatSync(fd).size;
|
|
97
|
+
let closed = false;
|
|
98
|
+
const handle = {
|
|
99
|
+
close: () => {
|
|
100
|
+
if (!closed)
|
|
101
|
+
closeSync(fd);
|
|
102
|
+
closed = true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const close = handle.close;
|
|
106
|
+
const stream = new ReadableStream({
|
|
107
|
+
pull(controller) {
|
|
108
|
+
try {
|
|
109
|
+
const chunk = new Uint8Array(CHUNK);
|
|
110
|
+
const read = readSync(fd, chunk, 0, CHUNK, null);
|
|
111
|
+
if (read === 0) {
|
|
112
|
+
close();
|
|
113
|
+
controller.close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
controller.enqueue(chunk.subarray(0, read));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
close();
|
|
120
|
+
controller.error(err);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
cancel: close,
|
|
124
|
+
}, { highWaterMark: 0 });
|
|
125
|
+
unreadFiles.register(stream, handle);
|
|
126
|
+
return { stream, size };
|
|
127
|
+
}
|
|
72
128
|
function kernelEmit(ctx, event) {
|
|
73
129
|
const write = kernelEmitters.get(ctx);
|
|
74
130
|
if (!write)
|
|
@@ -553,6 +609,89 @@ const PROCESS_HOST_DIRS = new Map();
|
|
|
553
609
|
const databaseBytesOf = (db) => db.pragma('page_count', { simple: true }) * db.pragma('page_size', { simple: true });
|
|
554
610
|
/** What a scope database's attachment rows sum to (#2501). */
|
|
555
611
|
const attachmentBytesOf = (db) => db.prepare(ATTACHMENT_BYTES_SQL).get().bytes;
|
|
612
|
+
/**
|
|
613
|
+
* The introspection reads, over an already-resolved connection: the bodies behind both the
|
|
614
|
+
* directory-checked admin verbs and the `*Local` verbs a vertical's `/internal/*` routes call by
|
|
615
|
+
* scope id alone (#2544). How the connection was reached, and what is recorded, stay with the caller.
|
|
616
|
+
*/
|
|
617
|
+
function scopeTablesOf(db) {
|
|
618
|
+
const names = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all().map((r) => r.name);
|
|
619
|
+
const count = (sql) => db.prepare(sql).get().c;
|
|
620
|
+
const tombstones = (name) => db.prepare(`SELECT name FROM pragma_table_info(?)`).all(name).some((c) => c.name === 'revoked_at');
|
|
621
|
+
return names.map((name) => ({
|
|
622
|
+
name,
|
|
623
|
+
rowCount: count(`SELECT COUNT(*) AS c FROM "${name}"`),
|
|
624
|
+
system: isSystemTable(name),
|
|
625
|
+
// #2531: rows whose `revoked_at` is null, for any table with that column (`ScopeTable.liveRowCount`; the Cloudflare adapter's twin).
|
|
626
|
+
...(tombstones(name) ? { liveRowCount: count(`SELECT COUNT(*) AS c FROM "${name}" WHERE revoked_at IS NULL`) } : {}),
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
function scopeTablePageOf(db, input) {
|
|
630
|
+
// Validate the table against the LIVE schema — an unknown name throws, it is
|
|
631
|
+
// never interpolated blind. That validated name is the only thing that reaches
|
|
632
|
+
// the query, so the quoted identifier below carries no user input.
|
|
633
|
+
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
634
|
+
if (!known.has(input.table))
|
|
635
|
+
throw substratError('not_found', `unknown table '${input.table}'`);
|
|
636
|
+
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
637
|
+
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
638
|
+
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
639
|
+
const offset = assertRowOffset('offset', input.offset ?? 0);
|
|
640
|
+
const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
|
|
641
|
+
const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
|
|
642
|
+
const columns = stmt.columns().map((c) => c.name);
|
|
643
|
+
const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
|
|
644
|
+
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
645
|
+
}
|
|
646
|
+
function scopeQueryOf(db, input) {
|
|
647
|
+
// Layer 1, shared: the kernel's textual gate (single statement, read verbs
|
|
648
|
+
// only) — the checked statement is the one that runs.
|
|
649
|
+
const sql = assertReadOnlyQuery(input.sql);
|
|
650
|
+
// Layer 2, authoritative: sqlite3_stmt_readonly via better-sqlite3. prepare()
|
|
651
|
+
// itself rejects multi-statement strings and bad SQL with the driver's message.
|
|
652
|
+
const stmt = db.prepare(sql);
|
|
653
|
+
if (!stmt.readonly)
|
|
654
|
+
throw substratError('validation_failed', 'read-only console: statement is not read-only');
|
|
655
|
+
if (!stmt.reader)
|
|
656
|
+
return { sql, reader: false, result: { columns: [], rows: [], truncated: false } };
|
|
657
|
+
stmt.raw(true);
|
|
658
|
+
const rows = [];
|
|
659
|
+
let truncated = false;
|
|
660
|
+
// Iterate rather than .all(): the row cap bounds what leaves the DB, so an
|
|
661
|
+
// over-broad SELECT costs a screenful, not the table.
|
|
662
|
+
for (const row of stmt.iterate()) {
|
|
663
|
+
if (rows.length >= SCOPE_QUERY_ROW_MAX) {
|
|
664
|
+
truncated = true;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
rows.push(row.map(cellToJson));
|
|
668
|
+
}
|
|
669
|
+
return { sql, reader: true, result: { columns: stmt.columns().map((c) => c.name), rows, truncated } };
|
|
670
|
+
}
|
|
671
|
+
function appliedMigrationsOf(db) {
|
|
672
|
+
const rows = db
|
|
673
|
+
.prepare(`SELECT module_id, version, applied_at, duration_ms, rows_changed FROM _substrat_migrations
|
|
674
|
+
ORDER BY applied_at DESC, rowid DESC`)
|
|
675
|
+
.all();
|
|
676
|
+
return rows.map((r) => ({
|
|
677
|
+
moduleId: r.module_id,
|
|
678
|
+
version: r.version,
|
|
679
|
+
appliedAt: r.applied_at ?? null,
|
|
680
|
+
durationMs: r.duration_ms,
|
|
681
|
+
rowsChanged: r.rows_changed,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
function undrainedEventsFrom(db, limit) {
|
|
685
|
+
const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`);
|
|
686
|
+
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
687
|
+
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
688
|
+
// rather than stalling the scope on it (#1636).
|
|
689
|
+
return undrainedEventsOf(readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit), 1000)));
|
|
690
|
+
}
|
|
691
|
+
const redrainCountOf = (db, drainedBefore) => db
|
|
692
|
+
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
693
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
694
|
+
.get(drainedBefore).c;
|
|
556
695
|
/**
|
|
557
696
|
* #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
|
|
558
697
|
* the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
|
|
@@ -1633,6 +1772,12 @@ export class SqliteScopeHost {
|
|
|
1633
1772
|
if (!open) {
|
|
1634
1773
|
throw new Error(`no live '${provider}' connection for tenant ${rt.tenantId} / vertical '${vertical}'`);
|
|
1635
1774
|
}
|
|
1775
|
+
const readSurface = () => this.buildAttachments(rt, { kind: 'connection', id: open.id }, this.attachmentStore(rt.tenantId, vertical), {
|
|
1776
|
+
reentrant: holdsActor,
|
|
1777
|
+
// #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
|
|
1778
|
+
// not a standing grant. See `admitByEvent` on `buildAttachments`.
|
|
1779
|
+
admitByEvent: eventId,
|
|
1780
|
+
});
|
|
1636
1781
|
return {
|
|
1637
1782
|
...open,
|
|
1638
1783
|
// The outbound read (#711), on THIS connection — so it is authorized as
|
|
@@ -1652,15 +1797,9 @@ export class SqliteScopeHost {
|
|
|
1652
1797
|
grants: async () => (await this.connectionGrantsInScope(rt.tenantId, rt.scopeId))
|
|
1653
1798
|
.filter((g) => g.connectionId === open.id)
|
|
1654
1799
|
.map((g) => g.permission),
|
|
1655
|
-
openAttachment: async (attachmentId) =>
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
reentrant: holdsActor,
|
|
1659
|
-
// #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
|
|
1660
|
-
// not a standing grant. See `admitByEvent` on `buildAttachments`.
|
|
1661
|
-
admitByEvent: eventId,
|
|
1662
|
-
}).open(attachmentId);
|
|
1663
|
-
},
|
|
1800
|
+
openAttachment: async (attachmentId) => readSurface().open(attachmentId),
|
|
1801
|
+
// #2514: the same read, its bytes a stream.
|
|
1802
|
+
openAttachmentStream: async (attachmentId) => readSurface().openStream(attachmentId),
|
|
1664
1803
|
// #687: open a cell the scope sealed TO this connection. The keyId-indexed
|
|
1665
1804
|
// map goes in, never out — a connector is handed the plaintext of one cell,
|
|
1666
1805
|
// not a private key it could mislay. Retired keys are in the map too, so a
|
|
@@ -2184,6 +2323,16 @@ export class SqliteScopeHost {
|
|
|
2184
2323
|
out.push(r);
|
|
2185
2324
|
}
|
|
2186
2325
|
};
|
|
2326
|
+
const contentTypeOf = (p) => {
|
|
2327
|
+
try {
|
|
2328
|
+
const meta = JSON.parse(readFileSync(`${p}.meta`, 'utf8'));
|
|
2329
|
+
return meta.contentType != null ? { contentType: meta.contentType } : {};
|
|
2330
|
+
}
|
|
2331
|
+
catch {
|
|
2332
|
+
// No sidecar (or unreadable): the caller falls back to the metadata row.
|
|
2333
|
+
return {};
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2187
2336
|
return this.wrapBlobStore({
|
|
2188
2337
|
put: async (key, body, opts) => {
|
|
2189
2338
|
const p = resolveKey(key);
|
|
@@ -2196,18 +2345,23 @@ export class SqliteScopeHost {
|
|
|
2196
2345
|
},
|
|
2197
2346
|
get: async (key) => {
|
|
2198
2347
|
const p = resolveKey(key);
|
|
2199
|
-
|
|
2200
|
-
return null;
|
|
2201
|
-
const body = new Uint8Array(readFileSync(p));
|
|
2202
|
-
let contentType;
|
|
2348
|
+
let body;
|
|
2203
2349
|
try {
|
|
2204
|
-
|
|
2205
|
-
contentType = meta.contentType ?? undefined;
|
|
2350
|
+
body = new Uint8Array(readFileSync(p));
|
|
2206
2351
|
}
|
|
2207
|
-
catch {
|
|
2208
|
-
//
|
|
2352
|
+
catch (err) {
|
|
2353
|
+
// Absent, or removed after the read gate: one answer either way, naming no host path (#2514).
|
|
2354
|
+
if (err.code === 'ENOENT')
|
|
2355
|
+
return null;
|
|
2356
|
+
throw err;
|
|
2209
2357
|
}
|
|
2210
|
-
return
|
|
2358
|
+
return { body, ...contentTypeOf(p) };
|
|
2359
|
+
},
|
|
2360
|
+
// #2514: the file read as the stream is read, never whole.
|
|
2361
|
+
getStream: async (key) => {
|
|
2362
|
+
const p = resolveKey(key);
|
|
2363
|
+
const file = fileStream(p);
|
|
2364
|
+
return file && { ...file, ...contentTypeOf(p) };
|
|
2211
2365
|
},
|
|
2212
2366
|
delete: async (key) => {
|
|
2213
2367
|
const p = resolveKey(key);
|
|
@@ -2464,6 +2618,21 @@ export class SqliteScopeHost {
|
|
|
2464
2618
|
}
|
|
2465
2619
|
};
|
|
2466
2620
|
const guarded = opts.reentrant ? reentrant : serialized;
|
|
2621
|
+
// The read gate, decided before the blob store is touched: a refused read never reaches it.
|
|
2622
|
+
const authorizeRead = (attachmentId) => guarded('attachments.open', async (ctx) => {
|
|
2623
|
+
const row = rt.db
|
|
2624
|
+
.prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
|
|
2625
|
+
.get(attachmentId);
|
|
2626
|
+
if (!row)
|
|
2627
|
+
return null;
|
|
2628
|
+
if (opts.admitByEvent !== undefined) {
|
|
2629
|
+
admitByDelivery(rt, opts.admitByEvent, row);
|
|
2630
|
+
return attachmentRecordOfRow(row);
|
|
2631
|
+
}
|
|
2632
|
+
const gate = targetGate(row.entity_type);
|
|
2633
|
+
assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2634
|
+
return attachmentRecordOfRow(row);
|
|
2635
|
+
});
|
|
2467
2636
|
return {
|
|
2468
2637
|
upload: async (input) => {
|
|
2469
2638
|
if (capabilitySession !== undefined) {
|
|
@@ -2551,20 +2720,7 @@ export class SqliteScopeHost {
|
|
|
2551
2720
|
return rows.map(attachmentRecordOfRow);
|
|
2552
2721
|
}),
|
|
2553
2722
|
open: async (attachmentId) => {
|
|
2554
|
-
const record = await
|
|
2555
|
-
const row = rt.db
|
|
2556
|
-
.prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
|
|
2557
|
-
.get(attachmentId);
|
|
2558
|
-
if (!row)
|
|
2559
|
-
return null;
|
|
2560
|
-
if (opts.admitByEvent !== undefined) {
|
|
2561
|
-
admitByDelivery(rt, opts.admitByEvent, row);
|
|
2562
|
-
return attachmentRecordOfRow(row);
|
|
2563
|
-
}
|
|
2564
|
-
const gate = targetGate(row.entity_type);
|
|
2565
|
-
assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2566
|
-
return attachmentRecordOfRow(row);
|
|
2567
|
-
});
|
|
2723
|
+
const record = await authorizeRead(attachmentId);
|
|
2568
2724
|
if (!record)
|
|
2569
2725
|
return null;
|
|
2570
2726
|
const obj = await store.get(attachmentBlobKey(rt.scopeId, record.id));
|
|
@@ -2577,6 +2733,14 @@ export class SqliteScopeHost {
|
|
|
2577
2733
|
}
|
|
2578
2734
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
2579
2735
|
},
|
|
2736
|
+
// #2514: the same gate, then the file as it is read, checked against the record as it passes.
|
|
2737
|
+
// The bytes are read after the gate's turn has ended, so a long read holds no actor turn.
|
|
2738
|
+
openStream: async (attachmentId) => {
|
|
2739
|
+
const record = await authorizeRead(attachmentId);
|
|
2740
|
+
if (!record)
|
|
2741
|
+
return null;
|
|
2742
|
+
return openAttachmentByteStream(store, attachmentBlobKey(rt.scopeId, record.id), record, nodeSha256);
|
|
2743
|
+
},
|
|
2580
2744
|
remove: async (attachmentId) => {
|
|
2581
2745
|
const removed = await guarded('attachments.remove', async (ctx, subject) => {
|
|
2582
2746
|
const row = rt.db
|
|
@@ -2623,7 +2787,31 @@ export class SqliteScopeHost {
|
|
|
2623
2787
|
};
|
|
2624
2788
|
}
|
|
2625
2789
|
async importScope(actor, input, dump) {
|
|
2790
|
+
await this.importScopeFrom(actor, input, dump, true);
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* `importScope`, with its role judgement (#2555) when `judge`: once before the scope is created,
|
|
2794
|
+
* so a refused import leaves nothing behind, and again inside the load's transaction, so a role
|
|
2795
|
+
* defined since that read is judged too. That second refusal comes after the scope was created, so
|
|
2796
|
+
* the scope goes with it. A snapshot passes false: it is the platform's copy of a live scope of this
|
|
2797
|
+
* host, made at once, so its seats are the ones that scope holds now (K-55).
|
|
2798
|
+
*/
|
|
2799
|
+
async importScopeFrom(actor, input, dump, judge) {
|
|
2626
2800
|
this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
|
|
2801
|
+
if (judge)
|
|
2802
|
+
this.refuseDumpRoles(input.tenantId, dump);
|
|
2803
|
+
let refusedInLoad = false;
|
|
2804
|
+
const recheck = judge
|
|
2805
|
+
? () => {
|
|
2806
|
+
try {
|
|
2807
|
+
this.refuseDumpRoles(input.tenantId, dump);
|
|
2808
|
+
}
|
|
2809
|
+
catch (e) {
|
|
2810
|
+
refusedInLoad = true;
|
|
2811
|
+
throw e;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
: undefined;
|
|
2627
2815
|
// Create the destination scope (directory row + storage). Its migration step
|
|
2628
2816
|
// creates some tables; the dump then replaces them wholesale, so the end state
|
|
2629
2817
|
// is the dump, not whatever the local module set would have built. Provenance is
|
|
@@ -2635,7 +2823,33 @@ export class SqliteScopeHost {
|
|
|
2635
2823
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
2636
2824
|
});
|
|
2637
2825
|
// A fork: its callers (snapshotScope) hand it a dump the platform exported.
|
|
2638
|
-
|
|
2826
|
+
try {
|
|
2827
|
+
await this.loadDump(input.tenantId, input.scopeId, { ...dump, exact: true }, undefined, recheck);
|
|
2828
|
+
}
|
|
2829
|
+
catch (e) {
|
|
2830
|
+
// The scope was created for this import alone and holds nothing loaded, so it goes with the
|
|
2831
|
+
// refusal, and a retry meets no scope. Best effort: the refusal is the answer whatever the
|
|
2832
|
+
// teardown does, and a teardown that fails is a staff-only ops row (no tenant on it), with the
|
|
2833
|
+
// scope left in `provisioning` for staff to reap. No admin row is written for the teardown:
|
|
2834
|
+
// the import never happened, and its `provisionScope` row stays as the record of the attempt.
|
|
2835
|
+
if (refusedInLoad) {
|
|
2836
|
+
await this.dropScopeOutright(input.tenantId, input.scopeId).catch((teardown) => {
|
|
2837
|
+
try {
|
|
2838
|
+
this.writeOpsFailure({
|
|
2839
|
+
actor,
|
|
2840
|
+
operation: 'importScope',
|
|
2841
|
+
stage: 'refused-import-teardown',
|
|
2842
|
+
scopeId: input.scopeId,
|
|
2843
|
+
message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
catch {
|
|
2847
|
+
// Nothing more to do here: the refusal below is still the answer.
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
throw e;
|
|
2852
|
+
}
|
|
2639
2853
|
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2640
2854
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
2641
2855
|
this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
@@ -2647,10 +2861,14 @@ export class SqliteScopeHost {
|
|
|
2647
2861
|
dump,
|
|
2648
2862
|
/** Run inside the load's own transaction, after the replay (#1742) — the restore's re-assert
|
|
2649
2863
|
* of the switch. It throws to roll the load back. */
|
|
2650
|
-
afterLoad
|
|
2864
|
+
afterLoad,
|
|
2865
|
+
/** Run first inside the load's own transaction, before any drop (#2555): the role judgement,
|
|
2866
|
+
* against the roles stored at the moment of the load. It throws to refuse with nothing moved. */
|
|
2867
|
+
beforeLoad) {
|
|
2651
2868
|
const rt = this.runtime(tenantId, scopeId);
|
|
2652
2869
|
const db = rt.db;
|
|
2653
2870
|
const load = db.transaction((dumped) => {
|
|
2871
|
+
beforeLoad?.();
|
|
2654
2872
|
// Deferred foreign keys cover the WHOLE drop-then-replay, drops included.
|
|
2655
2873
|
//
|
|
2656
2874
|
// Two distinct FK hazards, and the second is why this cannot wrap the inserts alone:
|
|
@@ -2797,6 +3015,8 @@ export class SqliteScopeHost {
|
|
|
2797
3015
|
if (!existing)
|
|
2798
3016
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2799
3017
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
3018
|
+
// #2555: judged before the fence, so a refused restore moves nothing; again inside the load.
|
|
3019
|
+
this.refuseDumpRoles(tenantId, dump);
|
|
2800
3020
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2801
3021
|
await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
|
|
2802
3022
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2806,7 +3026,7 @@ export class SqliteScopeHost {
|
|
|
2806
3026
|
const rows = [];
|
|
2807
3027
|
// #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
|
|
2808
3028
|
const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
|
|
2809
|
-
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
|
|
3029
|
+
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])), () => this.refuseDumpRoles(tenantId, dump));
|
|
2810
3030
|
await this.importScopeStores(tenantId, scopeId, dump.stores);
|
|
2811
3031
|
const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
|
|
2812
3032
|
for (const [action, after] of rows)
|
|
@@ -2825,7 +3045,7 @@ export class SqliteScopeHost {
|
|
|
2825
3045
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2826
3046
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
2827
3047
|
const snapshotId = ulid();
|
|
2828
|
-
await this.
|
|
3048
|
+
await this.importScopeFrom(actor, {
|
|
2829
3049
|
tenantId,
|
|
2830
3050
|
scopeId: snapshotId,
|
|
2831
3051
|
kind: opts?.kind ?? 'archive',
|
|
@@ -2834,7 +3054,7 @@ export class SqliteScopeHost {
|
|
|
2834
3054
|
jurisdiction: source.jurisdiction,
|
|
2835
3055
|
expiresAt: opts?.expiresAt,
|
|
2836
3056
|
// forkedFrom/forkedAt are stamped from the dump by importScope.
|
|
2837
|
-
}, dump);
|
|
3057
|
+
}, dump, false);
|
|
2838
3058
|
// Bind the snapshot to the source's current version so it is a runnable copy at the
|
|
2839
3059
|
// same frontier (a fresh bind, so it never re-triggers the snapshot path).
|
|
2840
3060
|
if (source.verticalVersionId) {
|
|
@@ -2854,6 +3074,16 @@ export class SqliteScopeHost {
|
|
|
2854
3074
|
throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
|
|
2855
3075
|
`archive a primary scope instead`);
|
|
2856
3076
|
}
|
|
3077
|
+
await this.dropScopeOutright(tenantId, scopeId);
|
|
3078
|
+
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
3079
|
+
forkedFrom: rec.forkedFrom,
|
|
3080
|
+
forkedAt: rec.forkedAt,
|
|
3081
|
+
expiresAt: rec.expiresAt,
|
|
3082
|
+
kind: rec.kind,
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
/** `deleteSnapshot`'s teardown, without its refusal or its audit row: the scope's stores, file and directory rows. */
|
|
3086
|
+
async dropScopeOutright(tenantId, scopeId) {
|
|
2857
3087
|
await this.wipeScopeStores(tenantId, scopeId);
|
|
2858
3088
|
// Close and evict the runtime handle before touching the file.
|
|
2859
3089
|
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
@@ -2873,12 +3103,6 @@ export class SqliteScopeHost {
|
|
|
2873
3103
|
forgetScopeStorage(redactionSqlOf(this.directory), scopeId);
|
|
2874
3104
|
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2875
3105
|
})();
|
|
2876
|
-
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2877
|
-
forkedFrom: rec.forkedFrom,
|
|
2878
|
-
forkedAt: rec.forkedAt,
|
|
2879
|
-
expiresAt: rec.expiresAt,
|
|
2880
|
-
kind: rec.kind,
|
|
2881
|
-
});
|
|
2882
3106
|
}
|
|
2883
3107
|
async getScope(principal, tenantId, scopeId, options) {
|
|
2884
3108
|
const row = this.directory
|
|
@@ -3871,6 +4095,19 @@ export class SqliteScopeHost {
|
|
|
3871
4095
|
if (refusal)
|
|
3872
4096
|
throw substratError('forbidden', refusal);
|
|
3873
4097
|
}
|
|
4098
|
+
/**
|
|
4099
|
+
* #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles
|
|
4100
|
+
* as this host stores them now, against the dump's role rows and seats, with the keys the modules
|
|
4101
|
+
* registered or listed here reserve and declare. Synchronous, so a caller inside the load's
|
|
4102
|
+
* transaction judges the roles stored at that moment.
|
|
4103
|
+
*/
|
|
4104
|
+
refuseDumpRoles(tenantId, dump) {
|
|
4105
|
+
const prefix = `${tenantId}/`;
|
|
4106
|
+
const roles = [...this.roles].filter(([k]) => k.startsWith(prefix)).map(([, r]) => r);
|
|
4107
|
+
const refusal = dumpRoleRefusal(roles, { reserved: this.reservedKeys, declared: this.declaredKeys }, dump, this.clock());
|
|
4108
|
+
if (refusal)
|
|
4109
|
+
throw substratError('forbidden', refusal);
|
|
4110
|
+
}
|
|
3874
4111
|
/**
|
|
3875
4112
|
* #2447: does anyone but a system principal hold a live seat in `roleKey` in this tenant — a
|
|
3876
4113
|
* tenant-level assignment, or a scope-level one in any of its scopes not reaped
|
|
@@ -3928,6 +4165,66 @@ export class SqliteScopeHost {
|
|
|
3928
4165
|
async attachmentSizeLocal(scopeId) {
|
|
3929
4166
|
return attachmentBytesOf(this.heldReadDb(scopeId));
|
|
3930
4167
|
}
|
|
4168
|
+
// -- the introspection reads a vertical's `/internal/*` routes make by scope id alone (#2544) --------
|
|
4169
|
+
// The Cloudflare host's `*Local` verbs, refused from the directory before any store is opened
|
|
4170
|
+
// (`heldReadDb`), so a scope this host does not hold is `conflict`, never an empty answer and never
|
|
4171
|
+
// a store file created for it. No audit row, as there: the control plane that called is the auditor.
|
|
4172
|
+
async facetEventsLocal(scopeId, input) {
|
|
4173
|
+
return facetEvents({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4174
|
+
}
|
|
4175
|
+
async eventEffectsLocal(scopeId, input) {
|
|
4176
|
+
return walkEventEffects({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxNodes);
|
|
4177
|
+
}
|
|
4178
|
+
async invocationEventsLocal(scopeId, input) {
|
|
4179
|
+
return readInvocation({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.invocationId, input.limit);
|
|
4180
|
+
}
|
|
4181
|
+
async deadLettersLocal(scopeId, input) {
|
|
4182
|
+
return readDeadLetters({ sql: scopedSql(this.heldReadDb(scopeId)) }, { limit: input.limit, cursor: input.cursor });
|
|
4183
|
+
}
|
|
4184
|
+
async lifecycleFlowLocal(scopeId, input) {
|
|
4185
|
+
return readLifecycleFlow({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4186
|
+
}
|
|
4187
|
+
async operationSeriesLocal(scopeId, input) {
|
|
4188
|
+
return readOperationSeries({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
|
|
4189
|
+
}
|
|
4190
|
+
async eventCauseLocal(scopeId, input) {
|
|
4191
|
+
return walkEventCause({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxDepth);
|
|
4192
|
+
}
|
|
4193
|
+
async entityHistoryLocal(scopeId, input) {
|
|
4194
|
+
return readHistory({ sql: scopedSql(this.heldReadDb(scopeId)) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
|
|
4195
|
+
}
|
|
4196
|
+
async appliedMigrationsLocal(scopeId) {
|
|
4197
|
+
return appliedMigrationsOf(this.heldReadDb(scopeId));
|
|
4198
|
+
}
|
|
4199
|
+
/** No PITR on plain SQLite: an empty list for a held scope, as `scopeMigrationBookmarks` answers. */
|
|
4200
|
+
async migrationBookmarksLocal(scopeId) {
|
|
4201
|
+
this.heldReadDb(scopeId);
|
|
4202
|
+
return [];
|
|
4203
|
+
}
|
|
4204
|
+
async introspectScopeTables(scopeId) {
|
|
4205
|
+
return scopeTablesOf(this.heldReadDb(scopeId));
|
|
4206
|
+
}
|
|
4207
|
+
/** The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope this host does not hold. */
|
|
4208
|
+
async introspectScopeTable(scopeId, input) {
|
|
4209
|
+
return scopeTablePageOf(this.heldReadDb(scopeId), input);
|
|
4210
|
+
}
|
|
4211
|
+
/**
|
|
4212
|
+
* The carry's read of a wiped copy's tombstone (#2563). Plain SQLite has no carry and no wipe, so
|
|
4213
|
+
* a store it does not hold is one it does not have: this reads what `introspectScopeTable` reads.
|
|
4214
|
+
*/
|
|
4215
|
+
async readTombstoneTable(scopeId, input) {
|
|
4216
|
+
return scopeTablePageOf(this.heldReadDb(scopeId), input);
|
|
4217
|
+
}
|
|
4218
|
+
/** The SQL console's CP-less path (#219), guarded as the table page is (#2563). */
|
|
4219
|
+
async introspectScopeQuery(scopeId, input) {
|
|
4220
|
+
return scopeQueryOf(this.heldReadDb(scopeId), input).result;
|
|
4221
|
+
}
|
|
4222
|
+
async undrainedEventsLocal(scopeId, limit) {
|
|
4223
|
+
return undrainedEventsFrom(this.heldReadDb(scopeId), Math.max(limit, 1));
|
|
4224
|
+
}
|
|
4225
|
+
async redrainCountLocal(scopeId, drainedBefore) {
|
|
4226
|
+
return redrainCountOf(this.heldReadDb(scopeId), drainedBefore);
|
|
4227
|
+
}
|
|
3931
4228
|
/**
|
|
3932
4229
|
* The read connection of a scope this host holds, for a read that names only the scope id (#2541),
|
|
3933
4230
|
* with the directory read ONCE. Refused `conflict` before anything is opened: no row, a reaped one
|
|
@@ -5937,6 +6234,20 @@ export class SqliteScopeHost {
|
|
|
5937
6234
|
.get(slugValue);
|
|
5938
6235
|
return r ? mapVertical(r) : undefined;
|
|
5939
6236
|
};
|
|
6237
|
+
/**
|
|
6238
|
+
* #2528: one vertical row update whose admin row takes its `before` (and whose guard runs, by
|
|
6239
|
+
* throwing) from the vertical as the write's own transaction reads it.
|
|
6240
|
+
*/
|
|
6241
|
+
const updateVertical = (actor, action, slugValue, set, args, judge, after) => {
|
|
6242
|
+
let before = null;
|
|
6243
|
+
this.directoryWriteAndAudit(() => {
|
|
6244
|
+
const existing = readVertical(slugValue);
|
|
6245
|
+
if (!existing)
|
|
6246
|
+
throw substratError('not_found', `unknown vertical '${slugValue}'`);
|
|
6247
|
+
before = judge(existing);
|
|
6248
|
+
return this.directory.prepare(`UPDATE verticals SET ${set} WHERE slug = ?`).run(...args, slugValue).changes > 0;
|
|
6249
|
+
}, () => this.recordAdmin(actor, action, { tenantId: null }, before, after));
|
|
6250
|
+
};
|
|
5940
6251
|
const mapVersion = (r) => verticalVersion.parse({
|
|
5941
6252
|
id: r.id,
|
|
5942
6253
|
verticalSlug: r.vertical_slug,
|
|
@@ -5963,6 +6274,14 @@ export class SqliteScopeHost {
|
|
|
5963
6274
|
}
|
|
5964
6275
|
return r ? mapVersion(r) : undefined;
|
|
5965
6276
|
};
|
|
6277
|
+
/** A version's admission, read synchronously so a transaction can judge it (#2528). */
|
|
6278
|
+
const versionAdmission = (id) => {
|
|
6279
|
+
const v = this.directory.prepare('SELECT admission, admission_note FROM vertical_versions WHERE id = ?')
|
|
6280
|
+
.get(id);
|
|
6281
|
+
if (!v)
|
|
6282
|
+
throw substratError('not_found', `unknown version ${id}`);
|
|
6283
|
+
return v;
|
|
6284
|
+
};
|
|
5966
6285
|
const mapOrg = (r) => orgSchema.parse({
|
|
5967
6286
|
id: r.org_id,
|
|
5968
6287
|
tenantId: r.tenant_id,
|
|
@@ -7100,15 +7419,15 @@ export class SqliteScopeHost {
|
|
|
7100
7419
|
},
|
|
7101
7420
|
publishVersion: async (actor, input) => {
|
|
7102
7421
|
const parsed = publishVersionInput.parse(input);
|
|
7103
|
-
const
|
|
7104
|
-
if (!
|
|
7105
|
-
throw
|
|
7106
|
-
}
|
|
7422
|
+
const unknownVertical = () => substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
|
|
7423
|
+
if (!readVertical(parsed.verticalSlug))
|
|
7424
|
+
throw unknownVertical();
|
|
7107
7425
|
// Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
|
|
7108
7426
|
// (tenant-owned, not listed), whose blast radius is its own tenant: there the
|
|
7109
7427
|
// sandbox contract is the gate and the version self-admits, noted so the
|
|
7110
|
-
// publish seam can tell a staff vouch from this shortcut.
|
|
7111
|
-
|
|
7428
|
+
// publish seam can tell a staff vouch from this shortcut. Judged in the insert's
|
|
7429
|
+
// transaction (#2528), on the vertical as it reads it there.
|
|
7430
|
+
let selfAdmits = false;
|
|
7112
7431
|
// The manifest is retained for the serving upload (#286), not audited — a whole
|
|
7113
7432
|
// manifest per publish would drown the admin log in bundle metadata.
|
|
7114
7433
|
const { manifestJson, origin, ...audited } = parsed;
|
|
@@ -7120,6 +7439,10 @@ export class SqliteScopeHost {
|
|
|
7120
7439
|
id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
|
|
7121
7440
|
});
|
|
7122
7441
|
this.directoryWriteAndAudit(() => {
|
|
7442
|
+
const owning = readVertical(parsed.verticalSlug);
|
|
7443
|
+
if (!owning)
|
|
7444
|
+
throw unknownVertical();
|
|
7445
|
+
selfAdmits = owning.ownerTenant !== null && !owning.listed;
|
|
7123
7446
|
this.directory
|
|
7124
7447
|
.prepare(`INSERT INTO vertical_versions
|
|
7125
7448
|
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
@@ -7168,85 +7491,66 @@ export class SqliteScopeHost {
|
|
|
7168
7491
|
this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
|
|
7169
7492
|
return hit;
|
|
7170
7493
|
},
|
|
7494
|
+
// #2528: each vertical write below reads its row's `before`, and runs its guard, inside the
|
|
7495
|
+
// transaction that writes (`updateVertical`).
|
|
7171
7496
|
setVerticalListed: async (actor, slug, listed) => {
|
|
7172
|
-
const existing = readVertical(slug);
|
|
7173
|
-
if (!existing)
|
|
7174
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7175
|
-
// Listing is the moment other tenants start trusting this code, so the
|
|
7176
|
-
// version they would install must carry a real staff vouch — an auto-admitted
|
|
7177
|
-
// prod version has never been read by anyone but its author.
|
|
7178
|
-
if (listed) {
|
|
7179
|
-
const prod = this.directory
|
|
7180
|
-
.prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
|
|
7181
|
-
.get(slug);
|
|
7182
|
-
const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
|
|
7183
|
-
if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
|
|
7184
|
-
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
7185
|
-
`a staff admit must vouch for it before listing`);
|
|
7186
|
-
}
|
|
7187
|
-
}
|
|
7188
7497
|
// Resolve any pending publish request either way, and set the flag.
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7498
|
+
updateVertical(actor, 'setVerticalListed', slug, 'listed = ?, publish_requested_at = NULL', [listed ? 1 : 0], (existing) => {
|
|
7499
|
+
// Listing is the moment other tenants start trusting this code, so the
|
|
7500
|
+
// version they would install must carry a real staff vouch — an auto-admitted
|
|
7501
|
+
// prod version has never been read by anyone but its author.
|
|
7502
|
+
if (listed) {
|
|
7503
|
+
const prod = this.directory
|
|
7504
|
+
.prepare(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
|
|
7505
|
+
WHERE c.vertical_slug = ? AND c.channel = 'prod'`)
|
|
7506
|
+
.get(slug);
|
|
7507
|
+
if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
7508
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
|
|
7509
|
+
`a staff admit must vouch for it before listing`);
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
return { listed: existing.listed };
|
|
7513
|
+
}, { listed });
|
|
7192
7514
|
},
|
|
7193
7515
|
requestPublish: async (actor, slug) => {
|
|
7194
|
-
|
|
7195
|
-
if (!existing)
|
|
7196
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7197
|
-
this.directoryWriteAndAudit(() => this.directory
|
|
7198
|
-
.prepare('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?')
|
|
7199
|
-
.run(new Date().toISOString(), slug).changes > 0, () => this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug }));
|
|
7516
|
+
updateVertical(actor, 'requestPublish', slug, 'publish_requested_at = ?', [new Date().toISOString()], () => null, { slug });
|
|
7200
7517
|
},
|
|
7201
7518
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
7202
|
-
|
|
7203
|
-
if (!existing)
|
|
7204
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7205
|
-
this.directoryWriteAndAudit(() => this.directory
|
|
7206
|
-
.prepare('UPDATE verticals SET installs_blocked = ? WHERE slug = ?')
|
|
7207
|
-
.run(blocked ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: existing.installsBlocked }, { installsBlocked: blocked }));
|
|
7519
|
+
updateVertical(actor, 'setVerticalInstallsBlocked', slug, 'installs_blocked = ?', [blocked ? 1 : 0], (existing) => ({ installsBlocked: existing.installsBlocked }), { installsBlocked: blocked });
|
|
7208
7520
|
},
|
|
7209
7521
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
7210
|
-
|
|
7211
|
-
if (!existing)
|
|
7212
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7213
|
-
this.directoryWriteAndAudit(() => this.directory
|
|
7214
|
-
.prepare('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?')
|
|
7215
|
-
.run(granted ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: existing.tenantProvisioner }, { tenantProvisioner: granted }));
|
|
7522
|
+
updateVertical(actor, 'setVerticalTenantProvisioner', slug, 'tenant_provisioner = ?', [granted ? 1 : 0], (existing) => ({ tenantProvisioner: existing.tenantProvisioner }), { tenantProvisioner: granted });
|
|
7216
7523
|
},
|
|
7217
7524
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
7218
|
-
|
|
7219
|
-
if (!existing)
|
|
7220
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7221
|
-
this.directoryWriteAndAudit(() => this.directory
|
|
7222
|
-
.prepare('UPDATE verticals SET email_sender = ? WHERE slug = ?')
|
|
7223
|
-
.run(granted ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: existing.emailSender }, { emailSender: granted }));
|
|
7525
|
+
updateVertical(actor, 'setVerticalEmailSender', slug, 'email_sender = ?', [granted ? 1 : 0], (existing) => ({ emailSender: existing.emailSender }), { emailSender: granted });
|
|
7224
7526
|
},
|
|
7225
7527
|
deleteVertical: async (actor, slug) => {
|
|
7226
|
-
const existing = readVertical(slug);
|
|
7227
|
-
if (!existing)
|
|
7228
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7229
|
-
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
7230
|
-
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
7231
|
-
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
7232
|
-
// "delete", because the app itself is already gone. `reaped` is terminal history
|
|
7233
|
-
// and never blocks. The count names the blast radius.
|
|
7234
|
-
const bound = this.directory
|
|
7235
|
-
.prepare('SELECT ' +
|
|
7236
|
-
"COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
|
|
7237
|
-
"COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
|
|
7238
|
-
'FROM scopes WHERE vertical = ?')
|
|
7239
|
-
.get(slug);
|
|
7240
|
-
if (bound.live > 0) {
|
|
7241
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
7242
|
-
}
|
|
7243
|
-
if (bound.archived > 0) {
|
|
7244
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
7245
|
-
}
|
|
7246
7528
|
// Deployed dispatch scripts are NOT reaped here — they become orphans for the
|
|
7247
7529
|
// cleanup script (#248), never destroyed alongside a registry row.
|
|
7248
|
-
// #2501: one transaction, so the row and its ownership period close together
|
|
7530
|
+
// #2501: one transaction, so the row and its ownership period close together; #2528: its
|
|
7531
|
+
// `before` and the bound-scope refusal are read in it too.
|
|
7532
|
+
let existing;
|
|
7249
7533
|
this.directoryWriteAndAudit(() => {
|
|
7534
|
+
existing = readVertical(slug);
|
|
7535
|
+
if (!existing)
|
|
7536
|
+
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
7537
|
+
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
7538
|
+
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
7539
|
+
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
7540
|
+
// "delete", because the app itself is already gone. `reaped` is terminal history
|
|
7541
|
+
// and never blocks. The count names the blast radius.
|
|
7542
|
+
const bound = this.directory
|
|
7543
|
+
.prepare('SELECT ' +
|
|
7544
|
+
"COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
|
|
7545
|
+
"COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
|
|
7546
|
+
'FROM scopes WHERE vertical = ?')
|
|
7547
|
+
.get(slug);
|
|
7548
|
+
if (bound.live > 0) {
|
|
7549
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
7550
|
+
}
|
|
7551
|
+
if (bound.archived > 0) {
|
|
7552
|
+
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
7553
|
+
}
|
|
7250
7554
|
this.directory.prepare('DELETE FROM vertical_channels WHERE vertical_slug = ?').run(slug);
|
|
7251
7555
|
this.directory.prepare('DELETE FROM vertical_channel_history WHERE vertical_slug = ?').run(slug);
|
|
7252
7556
|
this.directory
|
|
@@ -7267,47 +7571,40 @@ export class SqliteScopeHost {
|
|
|
7267
7571
|
this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
|
|
7268
7572
|
return periods;
|
|
7269
7573
|
},
|
|
7574
|
+
// #2528: the admission is judged, and the row's `before` read, in the transaction that writes.
|
|
7270
7575
|
admitVersion: async (actor, versionId) => {
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
.prepare('UPDATE vertical_versions SET admission_note = NULL WHERE id = ?')
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
}
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
7289
|
-
}
|
|
7290
|
-
this.directoryWriteAndAudit(() => this.directory
|
|
7291
|
-
.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
|
|
7292
|
-
.run(versionId).changes > 0, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, {
|
|
7293
|
-
admission: 'admitted',
|
|
7294
|
-
}));
|
|
7576
|
+
let row = { before: null, after: null };
|
|
7577
|
+
this.directoryWriteAndAudit(() => {
|
|
7578
|
+
const v = versionAdmission(versionId);
|
|
7579
|
+
if (v.admission === 'admitted') {
|
|
7580
|
+
// Idempotent — except an AUTO-admitted version, which this upgrades to a
|
|
7581
|
+
// manual vouch by clearing the note (what the publish seam requires).
|
|
7582
|
+
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
7583
|
+
return false;
|
|
7584
|
+
row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
|
|
7585
|
+
return this.directory.prepare('UPDATE vertical_versions SET admission_note = NULL WHERE id = ?').run(versionId).changes > 0;
|
|
7586
|
+
}
|
|
7587
|
+
if (v.admission === 'rejected') {
|
|
7588
|
+
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
7589
|
+
}
|
|
7590
|
+
row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
|
|
7591
|
+
return this.directory.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?").run(versionId).changes > 0;
|
|
7592
|
+
}, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, row.before, row.after));
|
|
7295
7593
|
},
|
|
7296
7594
|
rejectVersion: async (actor, versionId, note) => {
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
.
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
}));
|
|
7595
|
+
let before = null;
|
|
7596
|
+
this.directoryWriteAndAudit(() => {
|
|
7597
|
+
const v = versionAdmission(versionId);
|
|
7598
|
+
if (v.admission === 'admitted') {
|
|
7599
|
+
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
7600
|
+
}
|
|
7601
|
+
if (v.admission === 'rejected')
|
|
7602
|
+
return false; // idempotent
|
|
7603
|
+
before = { admission: v.admission };
|
|
7604
|
+
return this.directory
|
|
7605
|
+
.prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
|
|
7606
|
+
.run(note, versionId).changes > 0;
|
|
7607
|
+
}, () => this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, before, { admission: 'rejected', note }));
|
|
7311
7608
|
},
|
|
7312
7609
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
7313
7610
|
const incoming = await readVersion(versionId);
|
|
@@ -7716,10 +8013,27 @@ export class SqliteScopeHost {
|
|
|
7716
8013
|
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
7717
8014
|
// #2178: every install on the serving script runs the new version from this moment.
|
|
7718
8015
|
this.assertNoStrandFrom([r.serving_version_id], parsed.versionId, r.serving_ref ? this.provisionedVersionsOf(verticalSlug, r.serving_version_id ? { ref: r.serving_ref, versionId: r.serving_version_id } : null, null) : []);
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
8016
|
+
// #2528: a compare-and-set on the serving ref and version the gate judged
|
|
8017
|
+
// (`SERVES_NOW_CONDITION`), and the row's `before` read in the transaction that moves it,
|
|
8018
|
+
// as the Cloudflare host does.
|
|
8019
|
+
let before = null;
|
|
8020
|
+
let raced = false;
|
|
8021
|
+
this.directoryWriteAndAudit(() => {
|
|
8022
|
+
const now = this.directory
|
|
8023
|
+
.prepare('SELECT serving_ref, serving_version_id FROM verticals WHERE slug = ?')
|
|
8024
|
+
.get(verticalSlug);
|
|
8025
|
+
if (!now)
|
|
8026
|
+
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
8027
|
+
before = now.serving_ref ? { ref: now.serving_ref, versionId: now.serving_version_id } : null;
|
|
8028
|
+
const moved = this.directory
|
|
8029
|
+
.prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
|
|
8030
|
+
serving_do_classes = ?, serving_migration_tag = ? WHERE ${SERVES_NOW_CONDITION}`)
|
|
8031
|
+
.run(parsed.ref, parsed.versionId, JSON.stringify(parsed.doClasses), parsed.migrationTag, verticalSlug, r.serving_ref ?? null, r.serving_version_id ?? null).changes > 0;
|
|
8032
|
+
raced = !moved;
|
|
8033
|
+
return moved;
|
|
8034
|
+
}, () => this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, before, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
|
|
8035
|
+
if (raced)
|
|
8036
|
+
throw servingRaceRefusal(verticalSlug);
|
|
7723
8037
|
},
|
|
7724
8038
|
versionManifest: async (actor, verticalSlug, versionId) => {
|
|
7725
8039
|
const v = this.directory
|
|
@@ -7741,10 +8055,9 @@ export class SqliteScopeHost {
|
|
|
7741
8055
|
},
|
|
7742
8056
|
recordWireAuth: async (actor, input) => {
|
|
7743
8057
|
const row = deploymentWireAuth.parse(input);
|
|
7744
|
-
this.directoryWriteAndAudit(() => {
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
}, () => this.recordAdmin(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
8058
|
+
const written = this.directoryWriteAndAudit(() => writeDeploymentWireAuth(switchSqlOf(this.directory), row), () => this.recordAdmin(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
8059
|
+
if (!written)
|
|
8060
|
+
throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
|
|
7748
8061
|
},
|
|
7749
8062
|
listWireAuth: async (actor, input) => {
|
|
7750
8063
|
// The judged copy, not the caller's object: only its own fields, the reach first, reach the log.
|
|
@@ -7802,14 +8115,9 @@ export class SqliteScopeHost {
|
|
|
7802
8115
|
// operation's uncommitted event, the drain shipped it, and the rollback left the lake
|
|
7803
8116
|
// — which is append-only — holding an event the scope never had. The mark that
|
|
7804
8117
|
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
7805
|
-
const
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
7809
|
-
// rather than stalling the scope on it (#1636).
|
|
7810
|
-
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit ?? 200), 1000));
|
|
7811
|
-
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
7812
|
-
return undrainedEventsOf(read);
|
|
8118
|
+
const events = undrainedEventsFrom(this.scopeReadDbFor(tenantId, scopeId), limit ?? 200);
|
|
8119
|
+
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, events.length);
|
|
8120
|
+
return events;
|
|
7813
8121
|
},
|
|
7814
8122
|
markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
|
|
7815
8123
|
// BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
|
|
@@ -7938,10 +8246,7 @@ export class SqliteScopeHost {
|
|
|
7938
8246
|
// auditor asking "who counted this tenant's outbox, and how much was there" wants.
|
|
7939
8247
|
if (countOnly) {
|
|
7940
8248
|
// The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
|
|
7941
|
-
const redrainable = this.scopeReadDbFor(tenantId, scopeId)
|
|
7942
|
-
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
7943
|
-
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
7944
|
-
.get(drainedBefore).c;
|
|
8249
|
+
const redrainable = redrainCountOf(this.scopeReadDbFor(tenantId, scopeId), drainedBefore);
|
|
7945
8250
|
this.recordAccess(actor, 'redrainEvents', { tenantId, scopeId }, { drainedBefore, countOnly: true }, redrainable);
|
|
7946
8251
|
return redrainable;
|
|
7947
8252
|
}
|
|
@@ -8046,18 +8351,9 @@ export class SqliteScopeHost {
|
|
|
8046
8351
|
// Every row, newest first (#2553); `rowid DESC` orders migrations stamped in one millisecond.
|
|
8047
8352
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
8048
8353
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8049
|
-
const
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
.all();
|
|
8053
|
-
this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, rows.length);
|
|
8054
|
-
return rows.map((r) => ({
|
|
8055
|
-
moduleId: r.module_id,
|
|
8056
|
-
version: r.version,
|
|
8057
|
-
appliedAt: r.applied_at ?? null,
|
|
8058
|
-
durationMs: r.duration_ms,
|
|
8059
|
-
rowsChanged: r.rows_changed,
|
|
8060
|
-
}));
|
|
8354
|
+
const migrations = appliedMigrationsOf(db);
|
|
8355
|
+
this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, migrations.length);
|
|
8356
|
+
return migrations;
|
|
8061
8357
|
},
|
|
8062
8358
|
scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
|
|
8063
8359
|
const scope = this.directory
|
|
@@ -8210,8 +8506,10 @@ export class SqliteScopeHost {
|
|
|
8210
8506
|
const slugOwner = this.directory
|
|
8211
8507
|
.prepare('SELECT tenant_id FROM tenants WHERE slug = ?')
|
|
8212
8508
|
.get(parsed.slug);
|
|
8509
|
+
// #2556: the refusal never names the owner. Another tenant holds it, and the text
|
|
8510
|
+
// reaches whoever asked (a manager vertical's intent journal among them).
|
|
8213
8511
|
if (slugOwner) {
|
|
8214
|
-
throw substratError('conflict', `tenant slug '${parsed.slug}' already taken
|
|
8512
|
+
throw substratError('conflict', `tenant slug '${parsed.slug}' is already taken (slugs are unique)`);
|
|
8215
8513
|
}
|
|
8216
8514
|
this.directoryWriteAndAudit(() => this.directory
|
|
8217
8515
|
.prepare(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
@@ -8219,8 +8517,7 @@ export class SqliteScopeHost {
|
|
|
8219
8517
|
.run(parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null).changes > 0, () => this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, readTenant(parsed.id)));
|
|
8220
8518
|
},
|
|
8221
8519
|
setTenantStatus: async (actor, tenantId, status) => {
|
|
8222
|
-
|
|
8223
|
-
if (!before)
|
|
8520
|
+
if (!readTenant(tenantId))
|
|
8224
8521
|
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8225
8522
|
// `reaped` is terminal and destroys data — it is unreachable here and only
|
|
8226
8523
|
// ever set by reapTenant, so a plain status flip cannot forge a tombstone
|
|
@@ -8230,22 +8527,33 @@ export class SqliteScopeHost {
|
|
|
8230
8527
|
}
|
|
8231
8528
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
8232
8529
|
// entering `deleting` stamps, leaving it (an un-delete) clears — exactly the
|
|
8233
|
-
// shape suspendScope/archiveScope use for archived_at.
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8530
|
+
// shape suspendScope/archiveScope use for archived_at. #2528: the row's `before` is the
|
|
8531
|
+
// status the write's own transaction reads.
|
|
8532
|
+
let before;
|
|
8533
|
+
this.directoryWriteAndAudit(() => {
|
|
8534
|
+
before = readTenant(tenantId);
|
|
8535
|
+
if (!before)
|
|
8536
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8537
|
+
return (status === 'deleting'
|
|
8538
|
+
? this.directory
|
|
8539
|
+
.prepare('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?')
|
|
8540
|
+
.run(status, new Date().toISOString(), tenantId)
|
|
8541
|
+
: this.directory
|
|
8542
|
+
.prepare('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?')
|
|
8543
|
+
.run(status, tenantId)).changes > 0;
|
|
8544
|
+
}, () => this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status }));
|
|
8241
8545
|
},
|
|
8242
8546
|
setTenantName: async (actor, tenantId, name) => {
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8547
|
+
// A no-op rename is not audited — nothing changed. #2528: judged, and `before` read, in the
|
|
8548
|
+
// write's own transaction.
|
|
8549
|
+
let before;
|
|
8550
|
+
this.directoryWriteAndAudit(() => {
|
|
8551
|
+
before = readTenant(tenantId);
|
|
8552
|
+
if (!before)
|
|
8553
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8554
|
+
return before.name !== name
|
|
8555
|
+
&& this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId).changes > 0;
|
|
8556
|
+
}, () => this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before.name }, { name }));
|
|
8249
8557
|
},
|
|
8250
8558
|
listTenants: async (actor, page) => {
|
|
8251
8559
|
const where = [];
|
|
@@ -8392,6 +8700,9 @@ export class SqliteScopeHost {
|
|
|
8392
8700
|
recordStoreDriverRebuildRun: async (actor, entry) => {
|
|
8393
8701
|
this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
|
|
8394
8702
|
},
|
|
8703
|
+
recordWireWalk: async (actor, entry) => {
|
|
8704
|
+
this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry, this.clock());
|
|
8705
|
+
},
|
|
8395
8706
|
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
8396
8707
|
const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
8397
8708
|
// Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
|
|
@@ -8406,65 +8717,26 @@ export class SqliteScopeHost {
|
|
|
8406
8717
|
// K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
|
|
8407
8718
|
// a scope under a different tenant is unreachable, never another tenant's DB.
|
|
8408
8719
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8409
|
-
const
|
|
8410
|
-
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
8411
|
-
.all().map((r) => r.name);
|
|
8412
|
-
const tables = names.map((name) => ({
|
|
8413
|
-
name,
|
|
8414
|
-
rowCount: db.prepare(`SELECT COUNT(*) AS c FROM "${name}"`).get().c,
|
|
8415
|
-
system: isSystemTable(name),
|
|
8416
|
-
}));
|
|
8720
|
+
const tables = scopeTablesOf(db);
|
|
8417
8721
|
this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
|
|
8418
8722
|
return tables;
|
|
8419
8723
|
},
|
|
8420
8724
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
8421
8725
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
8426
|
-
if (!known.has(input.table))
|
|
8427
|
-
throw substratError('not_found', `unknown table '${input.table}'`);
|
|
8428
|
-
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
8429
|
-
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
8430
|
-
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
8431
|
-
const offset = assertRowOffset('offset', input.offset ?? 0);
|
|
8432
|
-
const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
|
|
8433
|
-
const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
|
|
8434
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
8435
|
-
const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
|
|
8436
|
-
this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit, offset }, rows.length);
|
|
8437
|
-
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
8726
|
+
const page = scopeTablePageOf(db, input);
|
|
8727
|
+
this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
8728
|
+
return page;
|
|
8438
8729
|
},
|
|
8439
8730
|
queryScope: async (actor, tenantId, scopeId, input) => {
|
|
8440
8731
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
8441
|
-
|
|
8442
|
-
//
|
|
8443
|
-
|
|
8444
|
-
|
|
8445
|
-
// itself rejects multi-statement strings and bad SQL with the driver's message.
|
|
8446
|
-
const stmt = db.prepare(sql);
|
|
8447
|
-
if (!stmt.readonly)
|
|
8448
|
-
throw substratError('validation_failed', 'read-only console: statement is not read-only');
|
|
8449
|
-
if (!stmt.reader)
|
|
8450
|
-
return { columns: [], rows: [], truncated: false };
|
|
8451
|
-
stmt.raw(true);
|
|
8452
|
-
const rows = [];
|
|
8453
|
-
let truncated = false;
|
|
8454
|
-
// Iterate rather than .all(): the row cap bounds what leaves the DB, so an
|
|
8455
|
-
// over-broad SELECT costs a screenful, not the table.
|
|
8456
|
-
for (const row of stmt.iterate()) {
|
|
8457
|
-
if (rows.length >= SCOPE_QUERY_ROW_MAX) {
|
|
8458
|
-
truncated = true;
|
|
8459
|
-
break;
|
|
8460
|
-
}
|
|
8461
|
-
rows.push(row.map(cellToJson));
|
|
8462
|
-
}
|
|
8463
|
-
const columns = stmt.columns().map((c) => c.name);
|
|
8732
|
+
const { sql, reader, result } = scopeQueryOf(db, input);
|
|
8733
|
+
// A statement that reads nothing returns before any row is recorded, as it always has.
|
|
8734
|
+
if (!reader)
|
|
8735
|
+
return result;
|
|
8464
8736
|
// The statement itself is the logged argument — the K-24 access log is the
|
|
8465
8737
|
// evidence trail for what staff read, and here the read IS the SQL.
|
|
8466
|
-
this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, rows.length);
|
|
8467
|
-
return
|
|
8738
|
+
this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, result.rows.length);
|
|
8739
|
+
return result;
|
|
8468
8740
|
},
|
|
8469
8741
|
// -- the denial log (K-35, #867) -------------------------------------
|
|
8470
8742
|
// Same shape as the introspection reads above: `scopeDbFor` is the K-3
|
|
@@ -8886,13 +9158,9 @@ export class SqliteScopeHost {
|
|
|
8886
9158
|
// caller (the reap route / grace-window sweep) has already reaped every scope's
|
|
8887
9159
|
// storage above the kernel; this clears the tenant's directory-side PII/config
|
|
8888
9160
|
// and flips the row to a `reaped` tombstone. Only a `deleting` tenant may be
|
|
8889
|
-
// reaped — an illegal source fails closed, like reapScope's archived-only gate.
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
8893
|
-
if (before.status !== 'deleting') {
|
|
8894
|
-
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
8895
|
-
}
|
|
9161
|
+
// reaped — an illegal source fails closed, like reapScope's archived-only gate. #2528: that
|
|
9162
|
+
// gate and the row's `before` are read in the transaction that reaps.
|
|
9163
|
+
let before;
|
|
8896
9164
|
// Clear the tenant's directory-side rows. Idempotent (set-to-empty), so a crash
|
|
8897
9165
|
// mid-reap converges on retry. KEPT as the tombstone: the `tenants` row itself
|
|
8898
9166
|
// and `_substrat_admin_log` (the compliance witness — never swept). Scope rows
|
|
@@ -8917,6 +9185,12 @@ export class SqliteScopeHost {
|
|
|
8917
9185
|
'_substrat_scope_attachment_storage', // #2501: attachment samples, gone with the tenant
|
|
8918
9186
|
];
|
|
8919
9187
|
this.directoryWriteAndAudit(() => {
|
|
9188
|
+
before = readTenant(tenantId);
|
|
9189
|
+
if (!before)
|
|
9190
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
9191
|
+
if (before.status !== 'deleting') {
|
|
9192
|
+
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
9193
|
+
}
|
|
8920
9194
|
for (const table of tables) {
|
|
8921
9195
|
this.directory.prepare(`DELETE FROM ${table} WHERE tenant_id = ?`).run(tenantId);
|
|
8922
9196
|
}
|