spine-framework 0.4.4 → 0.5.0-rc.5
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/.env.example +13 -3
- package/.framework/cli/commands/bootstrap.ts +392 -0
- package/.framework/cli/commands/dev.ts +91 -218
- package/.framework/cli/commands/doctor.ts +132 -26
- package/.framework/cli/commands/generate.ts +38 -298
- package/.framework/cli/commands/init.ts +20 -1
- package/.framework/cli/commands/install-app.ts +183 -13
- package/.framework/cli/commands/migrate.ts +36 -30
- package/.framework/cli/commands/migrations.ts +16 -56
- package/.framework/cli/commands/upgrade.ts +35 -0
- package/.framework/cli/commands/validate-app.ts +127 -1
- package/.framework/cli/commands/validate-schema.ts +147 -0
- package/.framework/cli/context.ts +223 -110
- package/.framework/cli/index.ts +6 -0
- package/.framework/cli/lib/project-files.ts +241 -1
- package/.framework/functions/_shared/app-manifest.ts +2 -0
- package/.framework/functions/_shared/hash-api-key.ts +183 -79
- package/.framework/functions/_shared/logging.ts +562 -224
- package/.framework/functions/_shared/middleware.ts +47 -44
- package/.framework/functions/_shared/permissions.ts +4 -0
- package/.framework/functions/admin-data.ts +51 -28
- package/.framework/functions/ai-agents.ts +10 -6
- package/.framework/functions/api-keys.ts +301 -122
- package/.framework/functions/apps.ts +63 -29
- package/.framework/functions/cil-ingest.ts +48 -15
- package/.framework/functions/debug-auth.ts +14 -8
- package/.framework/functions/deliveries.ts +39 -12
- package/.framework/functions/embeddings.ts +149 -78
- package/.framework/functions/integration-outbound.ts +67 -27
- package/.framework/functions/integrations.ts +254 -127
- package/.framework/functions/logs.ts +273 -133
- package/.framework/functions/observability.ts +278 -148
- package/.framework/functions/pipeline-executions.ts +1 -1
- package/.framework/functions/pipelines.ts +14 -9
- package/.framework/functions/prompt-configs.ts +7 -4
- package/.framework/functions/roles.ts +8 -5
- package/.framework/functions/security.ts +30 -17
- package/.framework/functions/system-cron.ts +240 -127
- package/.framework/functions/tests.ts +7 -6
- package/.framework/functions/timers.ts +310 -107
- package/.framework/functions/triggers.ts +28 -13
- package/.framework/functions/types.ts +13 -10
- package/.framework/migrations/069_log_stats_window.sql +54 -0
- package/.framework/migrations/070_app_type_schema_rpcs.sql +166 -0
- package/.framework/migrations/071_timer_interval_keys.sql +96 -0
- package/.framework/migrations/072_slug_autofill.sql +77 -0
- package/.framework/migrations/073_seed_member_roles.sql +26 -0
- package/.framework/migrations/074_app_route_prefix_domains.sql +168 -0
- package/.framework/migrations/075_security_definer_grants.sql +157 -0
- package/.framework/src/apps/admin/index.tsx +1141 -775
- package/.framework/src/components/ErrorBoundary.tsx +136 -0
- package/.framework/src/components/admin/AdminListPage.tsx +81 -73
- package/.framework/src/components/admin/AdminSidebar.tsx +88 -79
- package/.framework/src/components/admin/EntitySelect.tsx +181 -0
- package/.framework/src/components/admin/PageInstructions.tsx +86 -127
- package/.framework/src/components/app-shell/GenericDetailPage.tsx +37 -13
- package/.framework/src/components/app-shell/GenericListPage.tsx +28 -32
- package/.framework/src/components/layout/Sidebar.tsx +300 -177
- package/.framework/src/components/runtime/DataDetailPage.tsx +67 -35
- package/.framework/src/components/runtime/DataHeader.tsx +96 -48
- package/.framework/src/components/runtime/DataListPage.tsx +3 -26
- package/.framework/src/components/runtime/DataStats.tsx +10 -10
- package/.framework/src/components/runtime/DataTable.tsx +12 -10
- package/.framework/src/components/ui/sheet.tsx +24 -26
- package/.framework/src/components/ui/textarea.tsx +74 -48
- package/.framework/src/contexts/AppContext.tsx +5 -12
- package/.framework/src/contexts/AuthContext.tsx +17 -9
- package/.framework/src/hooks/useApps.ts +18 -22
- package/.framework/src/hooks/useEntityList.ts +12 -9
- package/.framework/src/hooks/useListSchema.ts +108 -58
- package/.framework/src/instructions/README.md +74 -0
- package/.framework/src/instructions/accounts.md +97 -0
- package/.framework/src/instructions/ai-agents.md +69 -0
- package/.framework/src/instructions/alerts.md +80 -0
- package/.framework/src/instructions/api-keys.md +87 -0
- package/.framework/src/instructions/apps.md +77 -0
- package/.framework/src/instructions/concepts/ai-and-prompts.md +150 -0
- package/.framework/src/instructions/concepts/apps-and-renderers.md +228 -0
- package/.framework/src/instructions/concepts/auth-and-api-keys.md +133 -0
- package/.framework/src/instructions/concepts/automation.md +186 -0
- package/.framework/src/instructions/concepts/concurrency-and-idempotency.md +125 -0
- package/.framework/src/instructions/concepts/core-vs-custom.md +124 -0
- package/.framework/src/instructions/concepts/design-schema.md +152 -0
- package/.framework/src/instructions/concepts/integrations-and-webhooks.md +127 -0
- package/.framework/src/instructions/concepts/observability.md +116 -0
- package/.framework/src/instructions/concepts/permissions.md +150 -0
- package/.framework/src/instructions/concepts/runtime-records.md +111 -0
- package/.framework/src/instructions/concepts/scope-and-accounts.md +112 -0
- package/.framework/src/instructions/concepts/spine-way.md +172 -0
- package/.framework/src/instructions/deliveries.md +59 -0
- package/.framework/src/instructions/guide/00-orientation.md +106 -0
- package/.framework/src/instructions/guide/01-install-and-first-run.md +253 -0
- package/.framework/src/instructions/guide/02-project-map.md +147 -0
- package/.framework/src/instructions/guide/03-domain-model.md +223 -0
- package/.framework/src/instructions/guide/04-first-app.md +227 -0
- package/.framework/src/instructions/guide/05-glossary.md +183 -0
- package/.framework/src/instructions/integrations.md +96 -0
- package/.framework/src/instructions/internal/cil-markers.md +83 -0
- package/.framework/src/instructions/internal/docs-contract.md +158 -0
- package/.framework/src/instructions/invites.md +60 -0
- package/.framework/src/instructions/kb.md +80 -0
- package/.framework/src/instructions/logs.md +77 -0
- package/.framework/src/instructions/notifications.md +77 -0
- package/.framework/src/instructions/observability.md +61 -0
- package/.framework/src/instructions/operations/deployment.md +115 -0
- package/.framework/src/instructions/operations/migrations.md +125 -0
- package/.framework/src/instructions/operations/security-checklist.md +170 -0
- package/.framework/src/instructions/operations/troubleshooting.md +170 -0
- package/.framework/src/instructions/operations/upgrade.md +116 -0
- package/.framework/src/instructions/people.md +91 -0
- package/.framework/src/instructions/pipeline-executions.md +62 -0
- package/.framework/src/instructions/pipelines.md +78 -0
- package/.framework/src/instructions/prompts.md +95 -0
- package/.framework/src/instructions/reference/api/admin-data.md +162 -0
- package/.framework/src/instructions/reference/api/auth.md +113 -0
- package/.framework/src/instructions/reference/api/errors-and-envelopes.md +113 -0
- package/.framework/src/instructions/reference/app-manifest.md +184 -0
- package/.framework/src/instructions/reference/cli.md +124 -0
- package/.framework/src/instructions/reference/database.md +132 -0
- package/.framework/src/instructions/reference/design-schema.md +280 -0
- package/.framework/src/instructions/reference/env-vars.md +133 -0
- package/.framework/src/instructions/reference/frontend-sdk.md +207 -0
- package/.framework/src/instructions/reference/functions/account-nodes.md +75 -0
- package/.framework/src/instructions/reference/functions/admin-data.md +197 -0
- package/.framework/src/instructions/reference/functions/admin-funnel.md +90 -0
- package/.framework/src/instructions/reference/functions/admin-install-funnel.md +107 -0
- package/.framework/src/instructions/reference/functions/admin-signals.md +102 -0
- package/.framework/src/instructions/reference/functions/ai-agents.md +148 -0
- package/.framework/src/instructions/reference/functions/ai-interactions.md +156 -0
- package/.framework/src/instructions/reference/functions/api-keys.md +130 -0
- package/.framework/src/instructions/reference/functions/apps.md +140 -0
- package/.framework/src/instructions/reference/functions/auth.md +99 -0
- package/.framework/src/instructions/reference/functions/debug-auth.md +91 -0
- package/.framework/src/instructions/reference/functions/deliveries.md +110 -0
- package/.framework/src/instructions/reference/functions/embeddings.md +166 -0
- package/.framework/src/instructions/reference/functions/eval-harness.md +149 -0
- package/.framework/src/instructions/reference/functions/index.md +103 -0
- package/.framework/src/instructions/reference/functions/integration-outbound.md +200 -0
- package/.framework/src/instructions/reference/functions/integration-routes.md +151 -0
- package/.framework/src/instructions/reference/functions/integrations.md +130 -0
- package/.framework/src/instructions/reference/functions/invites.md +182 -0
- package/.framework/src/instructions/reference/functions/item-progress.md +107 -0
- package/.framework/src/instructions/reference/functions/kb.md +166 -0
- package/.framework/src/instructions/reference/functions/logs.md +141 -0
- package/.framework/src/instructions/reference/functions/observability.md +105 -0
- package/.framework/src/instructions/reference/functions/pipeline-executions.md +132 -0
- package/.framework/src/instructions/reference/functions/pipeline-runner-background.md +121 -0
- package/.framework/src/instructions/reference/functions/pipelines.md +136 -0
- package/.framework/src/instructions/reference/functions/prompt-configs.md +121 -0
- package/.framework/src/instructions/reference/functions/roles.md +121 -0
- package/.framework/src/instructions/reference/functions/security.md +140 -0
- package/.framework/src/instructions/reference/functions/system-cron.md +136 -0
- package/.framework/src/instructions/reference/functions/system.md +106 -0
- package/.framework/src/instructions/reference/functions/tests.md +97 -0
- package/.framework/src/instructions/reference/functions/timers.md +117 -0
- package/.framework/src/instructions/reference/functions/trigger-scheduler.md +102 -0
- package/.framework/src/instructions/reference/functions/triggers.md +140 -0
- package/.framework/src/instructions/reference/functions/types.md +146 -0
- package/.framework/src/instructions/reference/seeds.md +126 -0
- package/.framework/src/instructions/reference/shared-modules.md +127 -0
- package/.framework/src/instructions/reference/spine-config.md +114 -0
- package/.framework/src/instructions/roles.md +80 -0
- package/.framework/src/instructions/runtime-collaboration.md +106 -0
- package/.framework/src/instructions/runtime-detail.md +70 -0
- package/.framework/src/instructions/runtime-list.md +77 -0
- package/.framework/src/instructions/security.md +67 -0
- package/.framework/src/instructions/tasks/add-a-pipeline.md +110 -0
- package/.framework/src/instructions/tasks/add-a-role.md +96 -0
- package/.framework/src/instructions/tasks/add-a-timer.md +114 -0
- package/.framework/src/instructions/tasks/add-a-trigger.md +106 -0
- package/.framework/src/instructions/tasks/add-a-type.md +151 -0
- package/.framework/src/instructions/tasks/add-a-webhook.md +108 -0
- package/.framework/src/instructions/tasks/add-an-ai-agent.md +108 -0
- package/.framework/src/instructions/tasks/add-an-integration.md +124 -0
- package/.framework/src/instructions/tasks/build-a-custom-page.md +118 -0
- package/.framework/src/instructions/tasks/document-your-app.md +117 -0
- package/.framework/src/instructions/tasks/package-and-install-an-app.md +136 -0
- package/.framework/src/instructions/tasks/register-an-app.md +137 -0
- package/.framework/src/instructions/tasks/test-your-code.md +108 -0
- package/.framework/src/instructions/tasks/write-a-function.md +132 -0
- package/.framework/src/instructions/tasks/write-a-migration.md +110 -0
- package/.framework/src/instructions/testing.md +59 -0
- package/.framework/src/instructions/timers.md +84 -0
- package/.framework/src/instructions/triggers.md +69 -0
- package/.framework/src/instructions/types.md +93 -0
- package/.framework/src/lib/app-mount.ts +88 -0
- package/.framework/src/pages/NotFoundPage.tsx +62 -31
- package/.framework/src/pages/admin/AIAgentDetailPage.tsx +117 -106
- package/.framework/src/pages/admin/AIAgentsPage.tsx +86 -48
- package/.framework/src/pages/admin/APIKeyDetailPage.tsx +153 -152
- package/.framework/src/pages/admin/APIKeysPage.tsx +91 -90
- package/.framework/src/pages/admin/AlertsConfigPage.tsx +29 -18
- package/.framework/src/pages/admin/AppDetailPage.tsx +6 -60
- package/.framework/src/pages/admin/AppsPage.tsx +104 -66
- package/.framework/src/pages/admin/DeliveriesPage.tsx +5 -5
- package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +5 -23
- package/.framework/src/pages/admin/EmbeddingsPage.tsx +5 -26
- package/.framework/src/pages/admin/IntegrationDetailPage.tsx +7 -7
- package/.framework/src/pages/admin/IntegrationsPage.tsx +85 -43
- package/.framework/src/pages/admin/LogsPage.tsx +144 -76
- package/.framework/src/pages/admin/ObservabilityDashboard.tsx +223 -140
- package/.framework/src/pages/admin/PipelineDetailPage.tsx +134 -113
- package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +108 -45
- package/.framework/src/pages/admin/PipelinesPage.tsx +160 -115
- package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +96 -98
- package/.framework/src/pages/admin/PromptConfigsPage.tsx +101 -59
- package/.framework/src/pages/admin/RoleDetailPage.tsx +90 -79
- package/.framework/src/pages/admin/RolesPage.tsx +86 -43
- package/.framework/src/pages/admin/SecurityPage.tsx +1 -1
- package/.framework/src/pages/admin/TestingDashboard.tsx +16 -28
- package/.framework/src/pages/admin/TimerDetailPage.tsx +117 -100
- package/.framework/src/pages/admin/TimersPage.tsx +90 -62
- package/.framework/src/pages/admin/TriggerDetailPage.tsx +90 -83
- package/.framework/src/pages/admin/TriggersPage.tsx +124 -99
- package/.framework/src/pages/admin/TypeDetailPage.tsx +152 -138
- package/.framework/src/pages/admin/TypesPage.tsx +90 -48
- package/.framework/src/pages/admin/cil/CILAgentPage.tsx +17 -25
- package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +17 -27
- package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +72 -40
- package/.framework/src/pages/admin/cil/CILFileListPage.tsx +321 -129
- package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +18 -29
- package/.framework/src/pages/admin/cil/CILGraphPage.tsx +26 -38
- package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +34 -46
- package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +189 -103
- package/.framework/src/pages/admin/cil/CILSearchPage.tsx +246 -136
- package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +11 -22
- package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +9 -20
- package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +26 -38
- package/.framework/src/pages/admin/kb/KBAdminPage.tsx +7 -26
- package/.framework/src/pages/auth/LoginPage.tsx +3 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +16 -8
- package/.framework/templates/agent/AGENTS.md +167 -0
- package/.framework/templates/agent/skills/spine-add-ai-agent/SKILL.md +58 -0
- package/.framework/templates/agent/skills/spine-add-automation/SKILL.md +62 -0
- package/.framework/templates/agent/skills/spine-add-integration/SKILL.md +63 -0
- package/.framework/templates/agent/skills/spine-architect/SKILL.md +86 -0
- package/.framework/templates/agent/skills/spine-install-app/SKILL.md +79 -0
- package/.framework/templates/agent/skills/spine-migration/SKILL.md +75 -0
- package/.framework/templates/agent/skills/spine-new-app/SKILL.md +79 -0
- package/.framework/templates/agent/skills/spine-new-function/SKILL.md +71 -0
- package/.framework/templates/agent/skills/spine-new-type/SKILL.md +72 -0
- package/.framework/templates/agent/skills/spine-schema/SKILL.md +77 -0
- package/.framework/templates/agent/skills/spine-test/SKILL.md +67 -0
- package/.framework/templates/agent/skills/spine-troubleshoot/SKILL.md +74 -0
- package/.framework/templates/agent/skills/spine-upgrade/SKILL.md +69 -0
- package/.framework/templates/project.json +4 -1
- package/.framework/tests/unit/account-scoping-gates.test.ts +394 -0
- package/.framework/tests/unit/agent-files.test.ts +355 -0
- package/.framework/tests/unit/agent-runner.test.ts +479 -44
- package/.framework/tests/unit/api-keys-create-scope-gate.test.ts +160 -0
- package/.framework/tests/unit/api-keys-omit-secret-fields.test.ts +38 -0
- package/.framework/tests/unit/app-mount.test.ts +80 -0
- package/.framework/tests/unit/bootstrap.test.ts +242 -0
- package/.framework/tests/unit/cli-context-api-key.test.ts +114 -0
- package/.framework/tests/unit/debug-auth.test.ts +38 -0
- package/.framework/tests/unit/deliveries-account-scoping.test.ts +185 -0
- package/.framework/tests/unit/integration-outbound-credentials.test.ts +167 -0
- package/.framework/tests/unit/integrations-audit-redaction.test.ts +130 -0
- package/.framework/tests/unit/logging-app-id.test.ts +75 -0
- package/.framework/tests/unit/security-definer-grants.test.ts +154 -0
- package/.framework/tests/unit/system-context-denial.test.ts +145 -0
- package/.framework/tests/unit/system-cron.test.ts +240 -0
- package/.framework/tests/unit/timers.test.ts +159 -0
- package/README.md +125 -166
- package/config/tsconfig.cli.json +2 -2
- package/package.json +15 -4
- package/templates/project/package-lock.json +2 -2
- package/.framework/docs/admin-routes.md +0 -76
- package/.framework/docs/api-docs-progress.md +0 -38
- package/.framework/docs/api-governance.md +0 -146
- package/.framework/docs/api-testing-results.md +0 -212
- package/.framework/docs/assembly-launch-guide.md +0 -138
- package/.framework/docs/audit-results.md +0 -590
- package/.framework/docs/db-api-inventory.md +0 -95
- package/.framework/docs/gap-remediation-backlog.md +0 -103
- package/.framework/docs/ui-adoption-verification.md +0 -111
- package/.framework/docs/ui-api-coverage.md +0 -84
- package/.framework/docs/v2-compatibility-audit.md +0 -228
- package/.framework/mcp/index.ts +0 -133
- package/.framework/mcp/load-env.ts +0 -60
- package/.framework/mcp/shared/app-manifest.ts +0 -275
- package/.framework/mcp/shared/db.ts +0 -477
- package/.framework/mcp/shared/kb.ts +0 -118
- package/.framework/mcp/tools/architect.ts +0 -431
- package/.framework/mcp/tools/cil/context.ts +0 -345
- package/.framework/mcp/tools/cil/graph.ts +0 -242
- package/.framework/mcp/tools/cil/ingest.ts +0 -1217
- package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +0 -693
- package/.framework/mcp/tools/cil/pipeline.ts +0 -767
- package/.framework/mcp/tools/cil/project.ts +0 -791
- package/.framework/mcp/tools/cil/retrieval.ts +0 -336
- package/.framework/mcp/tools/cil/search-core.ts +0 -454
- package/.framework/mcp/tools/cil/search.ts +0 -97
- package/.framework/mcp/tools/cil/suggest.ts +0 -186
- package/.framework/mcp/tools/cil/validate.ts +0 -1369
- package/.framework/mcp/tools/kb/delete.ts +0 -139
- package/.framework/mcp/tools/kb/index.ts +0 -52
- package/.framework/mcp/tools/kb/ingest.ts +0 -84
- package/.framework/mcp/tools/kb/list-types.ts +0 -102
- package/.framework/mcp/tools/kb/publish.ts +0 -100
- package/.framework/mcp/tools/kb/reindex.ts +0 -86
- package/.framework/mcp/tools/kb/revoke.ts +0 -85
- package/.framework/mcp/tools/kb/search.ts +0 -101
- package/.framework/mcp/tools/kb/upsert.ts +0 -125
- package/.framework/mcp/tools/observability.ts +0 -510
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// S-011: api-keys create accepted `scopes` and `is_internal` verbatim, so any
|
|
4
|
+
// scoped machine caller (service-role ctx.db) could mint a `*:*` or
|
|
5
|
+
// `is_internal` key and escalate itself. These tests pin the gate: wildcard
|
|
6
|
+
// scopes and internal keys are system-principal-only (403 otherwise), other
|
|
7
|
+
// requested scopes are clipped to the caller's own, and an omitted scopes
|
|
8
|
+
// list inherits the caller's scopes.
|
|
9
|
+
|
|
10
|
+
vi.mock('../../functions/_shared/middleware.ts', () => ({
|
|
11
|
+
createHandler: (fn: any) => fn,
|
|
12
|
+
json: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
parseBody: vi.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
18
|
+
adminDb: {
|
|
19
|
+
from: vi.fn(),
|
|
20
|
+
rpc: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
getUserDb: vi.fn(() => ({})),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
26
|
+
emitAudit: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
emitLog: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
emitSpan: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
emitActivity: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
vi.mock('../../functions/_shared/permissions.ts', () => ({
|
|
33
|
+
sanitizeRecordData: vi.fn(async (_ctx: any, record: any) => record),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
vi.mock('../../functions/_shared/logging.ts', () => ({
|
|
37
|
+
resolveAppUuid: vi.fn(async (ref: any) => ref ?? null),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
import { create } from '../../functions/api-keys.ts'
|
|
41
|
+
|
|
42
|
+
// Chainable query-builder stub — filter/chain methods return the builder;
|
|
43
|
+
// single/maybeSingle/await resolve to the queued result.
|
|
44
|
+
function q(result: { data?: any; error?: any }) {
|
|
45
|
+
const b: any = {}
|
|
46
|
+
const chainers = ['select', 'eq', 'in', 'is', 'order', 'limit', 'update',
|
|
47
|
+
'insert', 'upsert', 'delete', 'or', 'gte', 'lte', 'range']
|
|
48
|
+
for (const m of chainers) b[m] = vi.fn(() => b)
|
|
49
|
+
b.single = vi.fn(async () => result)
|
|
50
|
+
b.maybeSingle = vi.fn(async () => result)
|
|
51
|
+
b.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR)
|
|
52
|
+
return b
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let queue: any[] = []
|
|
56
|
+
let dbBuilt: any[] = []
|
|
57
|
+
|
|
58
|
+
function stubDb() {
|
|
59
|
+
return {
|
|
60
|
+
from: vi.fn(() => {
|
|
61
|
+
const b = queue.length ? queue.shift() : q({ data: null, error: null })
|
|
62
|
+
dbBuilt.push(b)
|
|
63
|
+
return b
|
|
64
|
+
}),
|
|
65
|
+
rpc: vi.fn(async () => ({ data: [], error: null })),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
queue = []
|
|
71
|
+
dbBuilt = []
|
|
72
|
+
vi.clearAllMocks()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const scopedMachine = (scopes: string[] = ['logs:read'], accountId: string | null = 'acct-1') => ({
|
|
76
|
+
id: 'key-1',
|
|
77
|
+
type: 'machine',
|
|
78
|
+
accountId,
|
|
79
|
+
roles: [],
|
|
80
|
+
scopes,
|
|
81
|
+
isInternal: false,
|
|
82
|
+
machineType: 'integration',
|
|
83
|
+
provenance: { sourceType: 'api_key' },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const systemAdmin = () => ({
|
|
87
|
+
id: 'user-1',
|
|
88
|
+
type: 'human',
|
|
89
|
+
accountId: 'acct-1',
|
|
90
|
+
roles: ['system_admin'],
|
|
91
|
+
provenance: { sourceType: 'jwt' },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function makeCtx(principal: any, overrides: any = {}) {
|
|
95
|
+
return {
|
|
96
|
+
requestId: 'req-1',
|
|
97
|
+
principal,
|
|
98
|
+
accountId: principal?.accountId ?? null,
|
|
99
|
+
db: stubDb(),
|
|
100
|
+
query: {},
|
|
101
|
+
headers: {},
|
|
102
|
+
...overrides,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Queue a successful insert for create() and return the builder's insert spy.
|
|
107
|
+
function queueInsert() {
|
|
108
|
+
const b = q({ data: { id: 'key-9', name: 'k', key_type: 'inbound' }, error: null })
|
|
109
|
+
queue.push(b)
|
|
110
|
+
return b
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('api-keys create privilege gate (S-011)', () => {
|
|
114
|
+
it('denies a scoped machine caller minting a *:* key with statusCode 403', async () => {
|
|
115
|
+
const ctx = makeCtx(scopedMachine())
|
|
116
|
+
await expect(
|
|
117
|
+
create(ctx, { name: 'escalated', scopes: ['*:*'] })
|
|
118
|
+
).rejects.toMatchObject({ statusCode: 403 })
|
|
119
|
+
expect(ctx.db.from).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('denies a scoped machine caller minting an is_internal key with statusCode 403', async () => {
|
|
123
|
+
const ctx = makeCtx(scopedMachine())
|
|
124
|
+
await expect(
|
|
125
|
+
create(ctx, { name: 'internal', is_internal: true })
|
|
126
|
+
).rejects.toMatchObject({ statusCode: 403 })
|
|
127
|
+
expect(ctx.db.from).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('clips requested scopes to the scopes the caller already holds', async () => {
|
|
131
|
+
const b = queueInsert()
|
|
132
|
+
const ctx = makeCtx(scopedMachine(['logs:read', 'items:read']))
|
|
133
|
+
await create(ctx, { name: 'clipped', scopes: ['logs:read', 'admin:write', '*'] })
|
|
134
|
+
expect(b.insert).toHaveBeenCalledWith(expect.objectContaining({
|
|
135
|
+
scopes: ['logs:read'],
|
|
136
|
+
is_internal: false,
|
|
137
|
+
}))
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('inherits the caller\u2019s own scopes when scopes is omitted', async () => {
|
|
141
|
+
const b = queueInsert()
|
|
142
|
+
const ctx = makeCtx(scopedMachine(['logs:read', 'items:write']))
|
|
143
|
+
await create(ctx, { name: 'inherits' })
|
|
144
|
+
expect(b.insert).toHaveBeenCalledWith(expect.objectContaining({
|
|
145
|
+
scopes: ['logs:read', 'items:write'],
|
|
146
|
+
is_internal: false,
|
|
147
|
+
}))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('lets a system_admin mint a wildcard-scoped internal key', async () => {
|
|
151
|
+
const b = queueInsert()
|
|
152
|
+
const ctx = makeCtx(systemAdmin())
|
|
153
|
+
const res = await create(ctx, { name: 'root', scopes: ['*:*'], is_internal: true })
|
|
154
|
+
expect(res.raw_key).toMatch(/^sk_/)
|
|
155
|
+
expect(b.insert).toHaveBeenCalledWith(expect.objectContaining({
|
|
156
|
+
scopes: ['*:*'],
|
|
157
|
+
is_internal: true,
|
|
158
|
+
}))
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { omitSecretFields } from '../../functions/_shared/hash-api-key'
|
|
3
|
+
|
|
4
|
+
describe('omitSecretFields', () => {
|
|
5
|
+
it('removes key_value and key_hash and keeps the other fields', () => {
|
|
6
|
+
const row = { id: 'key-1', name: 'Prod', key_prefix: 'sk_abc123', key_hash: 'hash', key_value: 'sk_secret' }
|
|
7
|
+
const result = omitSecretFields(row)
|
|
8
|
+
expect(result).toEqual({ id: 'key-1', name: 'Prod', key_prefix: 'sk_abc123' })
|
|
9
|
+
expect(result).not.toHaveProperty('key_value')
|
|
10
|
+
expect(result).not.toHaveProperty('key_hash')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('does not mutate the input row', () => {
|
|
14
|
+
const row = { id: 'key-1', key_value: 'sk_secret', key_hash: 'hash' }
|
|
15
|
+
omitSecretFields(row)
|
|
16
|
+
expect(row.key_value).toBe('sk_secret')
|
|
17
|
+
expect(row.key_hash).toBe('hash')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null and undefined input as-is', () => {
|
|
21
|
+
expect(omitSecretFields(null)).toBeNull()
|
|
22
|
+
expect(omitSecretFields(undefined)).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns an equal copy for a row without secret fields', () => {
|
|
26
|
+
const row = { id: 'key-1', name: 'Prod', is_active: true, metadata: { env: 'prod' } }
|
|
27
|
+
const result = omitSecretFields(row)
|
|
28
|
+
expect(result).toEqual(row)
|
|
29
|
+
expect(result).not.toBe(row)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('strips key_hash even when it is the only secret field present', () => {
|
|
33
|
+
const row = { id: 'key-2', name: 'CI', key_hash: 'pbkdf2$100000$salt$hash' }
|
|
34
|
+
const result = omitSecretFields(row)
|
|
35
|
+
expect(result).toEqual({ id: 'key-2', name: 'CI' })
|
|
36
|
+
expect(result).not.toHaveProperty('key_hash')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/app-mount.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/tests/unit/app-mount.test.ts"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Unit tests for resolveAppMount — host-keyed mount overrides on apps records"]
|
|
3
|
+
// CIL[DOC-CONTRACT inputs="resolveAppMount calls with injected hostname/environment" outputs="vitest assertions" errors="none" side_effects="none"]
|
|
4
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
5
|
+
// CIL[DOC-STABILITY stability="experimental"]
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { resolveAppMount } from '../../src/lib/app-mount.ts'
|
|
8
|
+
// CIL[END stable_id="~/.framework/tests/unit/app-mount.test.ts:module_header:module_header"]
|
|
9
|
+
|
|
10
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/app-mount.test.ts:fixtures:block" type="block" identity="fixtures" parent="none"]
|
|
11
|
+
// CIL[DOC-MACRO purpose="Shared app record fixture and default opts (localhost, prod env)"]
|
|
12
|
+
|
|
13
|
+
const base = {
|
|
14
|
+
route_prefix: '/demo' as string | null,
|
|
15
|
+
preview_domain: null as string | null,
|
|
16
|
+
prod_domain: null as string | null,
|
|
17
|
+
preview_route_prefix: null as string | null,
|
|
18
|
+
prod_route_prefix: null as string | null,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const opts = { hostname: 'localhost', environment: 'prod' }
|
|
22
|
+
// CIL[END stable_id="~/.framework/tests/unit/app-mount.test.ts:fixtures:block"]
|
|
23
|
+
|
|
24
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/app-mount.test.ts:resolveappmount_suite:block" type="block" identity="resolveappmount_suite" parent="none"]
|
|
25
|
+
// CIL[DOC-MACRO purpose="Cover default mount, host-matched override, non-matching fallback, legacy domain lock, preview env fields, URL/port domains, and idempotence"]
|
|
26
|
+
describe('resolveAppMount', () => {
|
|
27
|
+
it('returns the default mount when no domain is configured', () => {
|
|
28
|
+
expect(resolveAppMount({ ...base }, opts))
|
|
29
|
+
.toEqual({ routable: true, route_prefix: '/demo' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('mounts at prod_route_prefix when the host matches prod_domain', () => {
|
|
33
|
+
const app = { ...base, prod_domain: 'demo.answer-once.com', prod_route_prefix: '/' }
|
|
34
|
+
expect(resolveAppMount(app, { hostname: 'demo.answer-once.com', environment: 'prod' }))
|
|
35
|
+
.toEqual({ routable: true, route_prefix: '/' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('stays routable at the default mount on other hosts when an override exists', () => {
|
|
39
|
+
const app = { ...base, prod_domain: 'demo.answer-once.com', prod_route_prefix: '/' }
|
|
40
|
+
expect(resolveAppMount(app, opts))
|
|
41
|
+
.toEqual({ routable: true, route_prefix: '/demo' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('filters the app on other hosts when a domain is set without an override (legacy lock)', () => {
|
|
45
|
+
const app = { ...base, prod_domain: 'demo.answer-once.com' }
|
|
46
|
+
expect(resolveAppMount(app, opts)).toEqual({ routable: false, route_prefix: '/demo' })
|
|
47
|
+
expect(resolveAppMount(app, { hostname: 'demo.answer-once.com', environment: 'prod' }))
|
|
48
|
+
.toEqual({ routable: true, route_prefix: '/demo' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses preview fields when environment is preview', () => {
|
|
52
|
+
const app = {
|
|
53
|
+
...base,
|
|
54
|
+
preview_domain: 'staging.answer-once.com',
|
|
55
|
+
preview_route_prefix: '/',
|
|
56
|
+
prod_domain: 'demo.answer-once.com',
|
|
57
|
+
prod_route_prefix: '/',
|
|
58
|
+
}
|
|
59
|
+
expect(resolveAppMount(app, { hostname: 'staging.answer-once.com', environment: 'preview' }))
|
|
60
|
+
.toEqual({ routable: true, route_prefix: '/' })
|
|
61
|
+
// prod host does not match under preview env — falls back to default mount
|
|
62
|
+
expect(resolveAppMount(app, { hostname: 'demo.answer-once.com', environment: 'preview' }))
|
|
63
|
+
.toEqual({ routable: true, route_prefix: '/demo' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('accepts full URLs and host:port in domain fields', () => {
|
|
67
|
+
const app = { ...base, prod_domain: 'https://demo.answer-once.com/', prod_route_prefix: '/' }
|
|
68
|
+
expect(resolveAppMount(app, { hostname: 'demo.answer-once.com', environment: 'prod' }))
|
|
69
|
+
.toEqual({ routable: true, route_prefix: '/' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is idempotent — re-resolving a resolved record returns the same mount', () => {
|
|
73
|
+
const app = { ...base, prod_domain: 'demo.answer-once.com', prod_route_prefix: '/' }
|
|
74
|
+
const once = resolveAppMount(app, { hostname: 'demo.answer-once.com', environment: 'prod' })
|
|
75
|
+
const twice = resolveAppMount({ ...app, route_prefix: once.route_prefix },
|
|
76
|
+
{ hostname: 'demo.answer-once.com', environment: 'prod' })
|
|
77
|
+
expect(twice).toEqual(once)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
// CIL[END stable_id="~/.framework/tests/unit/app-mount.test.ts:resolveappmount_suite:block"]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { verifyApiKey } from '../../functions/_shared/hash-api-key'
|
|
3
|
+
|
|
4
|
+
// S-002: `spine-framework bootstrap` provisions the first system_admin and
|
|
5
|
+
// mints the first sk_ key without a browser. These tests pin the idempotent
|
|
6
|
+
// skip, the register-path row shape, the auth-user path, and the
|
|
7
|
+
// hash-only key storage.
|
|
8
|
+
|
|
9
|
+
type DbCall = { op: string; args: any[] }
|
|
10
|
+
type DbQuery = { table: string; calls: DbCall[] }
|
|
11
|
+
type DbResult = { data?: any; error?: any }
|
|
12
|
+
|
|
13
|
+
const supaState = vi.hoisted(() => ({
|
|
14
|
+
responder: null as null | ((q: DbQuery) => DbResult),
|
|
15
|
+
history: [] as DbQuery[],
|
|
16
|
+
createUser: vi.fn(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('@supabase/supabase-js', () => ({
|
|
20
|
+
createClient: () => ({
|
|
21
|
+
from(table: string) {
|
|
22
|
+
const calls: DbCall[] = []
|
|
23
|
+
const query: DbQuery = { table, calls }
|
|
24
|
+
const builder: any = {
|
|
25
|
+
select(...a: any[]) { calls.push({ op: 'select', args: a }); return builder },
|
|
26
|
+
upsert(...a: any[]) { calls.push({ op: 'upsert', args: a }); return builder },
|
|
27
|
+
update(...a: any[]) { calls.push({ op: 'update', args: a }); return builder },
|
|
28
|
+
insert(...a: any[]) { calls.push({ op: 'insert', args: a }); return builder },
|
|
29
|
+
eq(...a: any[]) { calls.push({ op: 'eq', args: a }); return builder },
|
|
30
|
+
neq(...a: any[]) { calls.push({ op: 'neq', args: a }); return builder },
|
|
31
|
+
in(...a: any[]) { calls.push({ op: 'in', args: a }); return builder },
|
|
32
|
+
is(...a: any[]) { calls.push({ op: 'is', args: a }); return builder },
|
|
33
|
+
or(...a: any[]) { calls.push({ op: 'or', args: a }); return builder },
|
|
34
|
+
order(...a: any[]) { calls.push({ op: 'order', args: a }); return builder },
|
|
35
|
+
limit(...a: any[]) { calls.push({ op: 'limit', args: a }); return builder },
|
|
36
|
+
range(...a: any[]) { calls.push({ op: 'range', args: a }); return builder },
|
|
37
|
+
single() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
|
|
38
|
+
maybeSingle() { supaState.history.push(query); return Promise.resolve(supaState.responder!(query)) },
|
|
39
|
+
then(res: any, rej: any) {
|
|
40
|
+
supaState.history.push(query)
|
|
41
|
+
return Promise.resolve(supaState.responder!(query)).then(res, rej)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
return builder
|
|
45
|
+
},
|
|
46
|
+
auth: {
|
|
47
|
+
admin: {
|
|
48
|
+
createUser: (...args: any[]) => supaState.createUser(...args),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
55
|
+
emitAudit: vi.fn(async () => {}),
|
|
56
|
+
emitSpan: vi.fn(async () => {}),
|
|
57
|
+
emitLog: vi.fn(async () => {}),
|
|
58
|
+
emitActivity: vi.fn(async () => {}),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
import { createClient } from '@supabase/supabase-js'
|
|
62
|
+
import { runBootstrap } from '../../cli/commands/bootstrap'
|
|
63
|
+
import { emitLog } from '../../functions/_shared/audit'
|
|
64
|
+
|
|
65
|
+
// Per-test table state. `people` routes on recorded ops: `limit` = the
|
|
66
|
+
// idempotent system_admin existence check, `insert`/`update` = writes,
|
|
67
|
+
// anything else = the email lookup (maybeSingle).
|
|
68
|
+
const tables = {
|
|
69
|
+
account: { id: 'acct-spine' } as any,
|
|
70
|
+
role: { id: 'role-admin' } as any,
|
|
71
|
+
personType: { id: 'type-person', design_schema: { fields: {} }, validation_schema: {} } as any,
|
|
72
|
+
existingAdmins: [] as any[],
|
|
73
|
+
personByEmail: null as any,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultResponder(q: DbQuery): DbResult {
|
|
77
|
+
const ops = q.calls.map((c) => c.op)
|
|
78
|
+
switch (q.table) {
|
|
79
|
+
case 'accounts':
|
|
80
|
+
return { data: tables.account, error: null }
|
|
81
|
+
case 'roles':
|
|
82
|
+
return { data: tables.role, error: null }
|
|
83
|
+
case 'types':
|
|
84
|
+
return { data: tables.personType, error: null }
|
|
85
|
+
case 'people':
|
|
86
|
+
if (ops.includes('insert')) return { data: { id: 'person-new' }, error: null }
|
|
87
|
+
if (ops.includes('update')) return { data: null, error: null }
|
|
88
|
+
if (ops.includes('limit')) return { data: tables.existingAdmins, error: null }
|
|
89
|
+
return { data: tables.personByEmail, error: null }
|
|
90
|
+
case 'api_keys': {
|
|
91
|
+
const row = q.calls.find((c) => c.op === 'insert')?.args?.[0] || {}
|
|
92
|
+
return {
|
|
93
|
+
data: { id: 'key-1', name: row.name, key_prefix: row.key_prefix, scopes: row.scopes },
|
|
94
|
+
error: null,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return { data: null, error: null }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queriesTo(table: string, op?: string): DbQuery[] {
|
|
103
|
+
return supaState.history.filter(
|
|
104
|
+
(q) => q.table === table && (!op || q.calls.some((c) => c.op === op))
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function insertedRow(table: string): any {
|
|
109
|
+
const q = queriesTo(table, 'insert')[0]
|
|
110
|
+
return q?.calls.find((c) => c.op === 'insert')?.args?.[0]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
supaState.history = []
|
|
115
|
+
supaState.createUser.mockReset()
|
|
116
|
+
vi.mocked(emitLog).mockClear()
|
|
117
|
+
tables.account = { id: 'acct-spine' }
|
|
118
|
+
tables.role = { id: 'role-admin' }
|
|
119
|
+
tables.personType = { id: 'type-person', design_schema: { fields: {} }, validation_schema: {} }
|
|
120
|
+
tables.existingAdmins = []
|
|
121
|
+
tables.personByEmail = null
|
|
122
|
+
supaState.responder = defaultResponder
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
function db() {
|
|
126
|
+
return createClient('https://example.supabase.co', 'service-key') as any
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('bootstrap idempotency', () => {
|
|
130
|
+
it('skips person creation when a system_admin already exists, still mints a key', async () => {
|
|
131
|
+
tables.existingAdmins = [{ id: 'person-admin', email: 'root@x.com' }]
|
|
132
|
+
|
|
133
|
+
const result = await runBootstrap(db(), {})
|
|
134
|
+
|
|
135
|
+
expect(result.admin).toBe('existing')
|
|
136
|
+
expect(result.personId).toBe('person-admin')
|
|
137
|
+
expect(result.authUserCreated).toBe(false)
|
|
138
|
+
expect(queriesTo('people', 'insert')).toHaveLength(0)
|
|
139
|
+
expect(queriesTo('people', 'update')).toHaveLength(0)
|
|
140
|
+
expect(queriesTo('api_keys', 'insert')).toHaveLength(1)
|
|
141
|
+
expect(result.apiKey.raw_key).toMatch(/^sk_[0-9a-f]{32}$/)
|
|
142
|
+
// Only the key mint is audited — no person.created event.
|
|
143
|
+
const events = vi.mocked(emitLog).mock.calls.map((c) => c[1])
|
|
144
|
+
expect(events).toEqual(['api_key.created'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('creates the first admin person (db-only) when none exists', async () => {
|
|
148
|
+
const result = await runBootstrap(db(), {})
|
|
149
|
+
|
|
150
|
+
expect(result.admin).toBe('created')
|
|
151
|
+
expect(result.authUserCreated).toBe(false)
|
|
152
|
+
expect(result.personEmail).toBe('admin@spine.local')
|
|
153
|
+
|
|
154
|
+
const row = insertedRow('people')
|
|
155
|
+
// Register-path (handle_new_auth_user) column parity.
|
|
156
|
+
expect(row.account_id).toBe('acct-spine')
|
|
157
|
+
expect(row.role_id).toBe('role-admin')
|
|
158
|
+
expect(row.type_id).toBe('type-person')
|
|
159
|
+
expect(row.status).toBe('active')
|
|
160
|
+
expect(row.is_active).toBe(true)
|
|
161
|
+
expect(row.auth_uid).toBeNull()
|
|
162
|
+
expect(row.email).toBe('admin@spine.local')
|
|
163
|
+
expect(row.full_name).toBe('admin')
|
|
164
|
+
expect(row.design_schema).toEqual({ fields: {} })
|
|
165
|
+
expect(supaState.createUser).not.toHaveBeenCalled()
|
|
166
|
+
expect(vi.mocked(emitLog).mock.calls.map((c) => c[1])).toEqual([
|
|
167
|
+
'person.created',
|
|
168
|
+
'api_key.created',
|
|
169
|
+
])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('creates a confirmed auth user + person when --email/--password are given', async () => {
|
|
173
|
+
supaState.createUser.mockResolvedValue({
|
|
174
|
+
data: { user: { id: 'auth-1' } },
|
|
175
|
+
error: null,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await runBootstrap(db(), {
|
|
179
|
+
email: 'me@x.com',
|
|
180
|
+
password: 's3cret',
|
|
181
|
+
fullName: 'Me',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
expect(supaState.createUser).toHaveBeenCalledWith({
|
|
185
|
+
email: 'me@x.com',
|
|
186
|
+
password: 's3cret',
|
|
187
|
+
email_confirm: true,
|
|
188
|
+
user_metadata: { full_name: 'Me' },
|
|
189
|
+
})
|
|
190
|
+
expect(result.authUserCreated).toBe(true)
|
|
191
|
+
const row = insertedRow('people')
|
|
192
|
+
// Register path: person id === auth user id, auth_uid === auth user id.
|
|
193
|
+
expect(row.id).toBe('auth-1')
|
|
194
|
+
expect(row.auth_uid).toBe('auth-1')
|
|
195
|
+
expect(row.email).toBe('me@x.com')
|
|
196
|
+
expect(row.full_name).toBe('Me')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('rejects --password without --email before any DB work', async () => {
|
|
200
|
+
await expect(runBootstrap(db(), { password: 'x' })).rejects.toThrow('--password requires --email')
|
|
201
|
+
expect(supaState.history).toHaveLength(0)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('fails clearly when the spine-system account seed is missing', async () => {
|
|
205
|
+
tables.account = null
|
|
206
|
+
await expect(runBootstrap(db(), {})).rejects.toThrow(/spine-system account not found/)
|
|
207
|
+
expect(queriesTo('api_keys', 'insert')).toHaveLength(0)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('bootstrap api key minting', () => {
|
|
212
|
+
it('stores only the hash — raw key is returned once and verifies', async () => {
|
|
213
|
+
const result = await runBootstrap(db(), {})
|
|
214
|
+
|
|
215
|
+
const keyRow = insertedRow('api_keys')
|
|
216
|
+
expect(keyRow.key_hash).toMatch(/^pbkdf2\$\d+\$/)
|
|
217
|
+
expect(keyRow.key_prefix).toBe(result.apiKey.raw_key.slice(0, 8))
|
|
218
|
+
expect(keyRow.raw_key).toBeUndefined()
|
|
219
|
+
expect(keyRow.key_value).toBeUndefined()
|
|
220
|
+
expect(verifyApiKey(result.apiKey.raw_key, keyRow.key_hash)).toBe(true)
|
|
221
|
+
expect(keyRow.is_active).toBe(true)
|
|
222
|
+
expect(keyRow.created_by).toBe(result.personId)
|
|
223
|
+
expect(keyRow.account_id).toBe('acct-spine')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('defaults to the system_admin wildcard scope', async () => {
|
|
227
|
+
const result = await runBootstrap(db(), {})
|
|
228
|
+
expect(result.apiKey.scopes).toEqual(['*:*'])
|
|
229
|
+
expect(insertedRow('api_keys').scopes).toEqual(['*:*'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('honors repeated --scope flags for narrower keys', async () => {
|
|
233
|
+
const result = await runBootstrap(db(), { scopes: ['items:read', 'items:write'] })
|
|
234
|
+
expect(insertedRow('api_keys').scopes).toEqual(['items:read', 'items:write'])
|
|
235
|
+
expect(result.apiKey.scopes).toEqual(['items:read', 'items:write'])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('maps the literal system_admin scope to *:*', async () => {
|
|
239
|
+
const result = await runBootstrap(db(), { scopes: ['system_admin'] })
|
|
240
|
+
expect(result.apiKey.scopes).toEqual(['*:*'])
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { hashApiKey } from '../../functions/_shared/hash-api-key'
|
|
3
|
+
|
|
4
|
+
const { mockFrom, mockLimit } = vi.hoisted(() => ({
|
|
5
|
+
mockFrom: vi.fn(),
|
|
6
|
+
mockLimit: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
vi.mock('../../functions/_shared/index.ts', () => ({
|
|
10
|
+
adminDb: { from: mockFrom },
|
|
11
|
+
getUserDb: vi.fn(),
|
|
12
|
+
SYSTEM_PRINCIPAL: { id: 'system', type: 'system' },
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const ENV_KEYS = [
|
|
16
|
+
'SUPABASE_URL',
|
|
17
|
+
'SUPABASE_SERVICE_ROLE_KEY',
|
|
18
|
+
'SPINE_CLI_API_KEY',
|
|
19
|
+
'SPINE_CLI_JWT',
|
|
20
|
+
'SPINE_CLI_ACCOUNT_ID',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const savedEnv: Record<string, string | undefined> = {}
|
|
24
|
+
|
|
25
|
+
async function loadContext() {
|
|
26
|
+
vi.resetModules()
|
|
27
|
+
return await import('../../cli/context.ts')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks()
|
|
32
|
+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key]
|
|
33
|
+
process.env.SUPABASE_URL = 'https://example.supabase.co'
|
|
34
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-test-key'
|
|
35
|
+
delete process.env.SPINE_CLI_API_KEY
|
|
36
|
+
delete process.env.SPINE_CLI_JWT
|
|
37
|
+
delete process.env.SPINE_CLI_ACCOUNT_ID
|
|
38
|
+
|
|
39
|
+
mockFrom.mockReturnValue({
|
|
40
|
+
select: vi.fn(() => ({
|
|
41
|
+
eq: vi.fn(() => ({
|
|
42
|
+
eq: vi.fn(() => ({
|
|
43
|
+
order: vi.fn(() => ({ limit: mockLimit })),
|
|
44
|
+
})),
|
|
45
|
+
})),
|
|
46
|
+
})),
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
for (const key of ENV_KEYS) {
|
|
52
|
+
if (savedEnv[key] === undefined) {
|
|
53
|
+
delete process.env[key]
|
|
54
|
+
} else {
|
|
55
|
+
process.env[key] = savedEnv[key]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('buildCliContext API key auth', () => {
|
|
61
|
+
it('returns a machine principal for a valid raw key', async () => {
|
|
62
|
+
const rawKey = 'sk_validrawkey0001'
|
|
63
|
+
const { key_hash } = hashApiKey(rawKey)
|
|
64
|
+
mockLimit.mockResolvedValue({
|
|
65
|
+
data: [{ id: 'key-id-1', account_id: 'acct-1', scopes: ['read'], key_hash, expires_at: null }],
|
|
66
|
+
error: null,
|
|
67
|
+
})
|
|
68
|
+
process.env.SPINE_CLI_API_KEY = rawKey
|
|
69
|
+
|
|
70
|
+
const { buildCliContext } = await loadContext()
|
|
71
|
+
const ctx = await buildCliContext()
|
|
72
|
+
|
|
73
|
+
expect(ctx.principal.type).toBe('machine')
|
|
74
|
+
expect(ctx.principal.id).toBe('key-id-1')
|
|
75
|
+
expect(ctx.principal.provenance.apiKeyId).toBe('key-id-1')
|
|
76
|
+
expect(ctx.principal.provenance.sourceType).toBe('api_key')
|
|
77
|
+
expect(ctx.accountId).toBe('acct-1')
|
|
78
|
+
expect(ctx.requestId).toBeTruthy()
|
|
79
|
+
expect(mockFrom).toHaveBeenCalledWith('api_keys')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('rejects a wrong key sharing the same prefix', async () => {
|
|
83
|
+
const { key_hash } = hashApiKey('sk_wrongxx_other')
|
|
84
|
+
mockLimit.mockResolvedValue({
|
|
85
|
+
data: [{ id: 'key-id-1', account_id: 'acct-1', scopes: [], key_hash, expires_at: null }],
|
|
86
|
+
error: null,
|
|
87
|
+
})
|
|
88
|
+
process.env.SPINE_CLI_API_KEY = 'sk_wrongxx_guess'
|
|
89
|
+
|
|
90
|
+
const { buildCliContext } = await loadContext()
|
|
91
|
+
await expect(buildCliContext()).rejects.toThrow('Invalid or inactive SPINE_CLI_API_KEY')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('skips expired rows and rejects when only expired keys match', async () => {
|
|
95
|
+
const rawKey = 'sk_expiredkey001'
|
|
96
|
+
const { key_hash } = hashApiKey(rawKey)
|
|
97
|
+
mockLimit.mockResolvedValue({
|
|
98
|
+
data: [{ id: 'key-id-1', account_id: 'acct-1', scopes: [], key_hash, expires_at: '2000-01-01T00:00:00Z' }],
|
|
99
|
+
error: null,
|
|
100
|
+
})
|
|
101
|
+
process.env.SPINE_CLI_API_KEY = rawKey
|
|
102
|
+
|
|
103
|
+
const { buildCliContext } = await loadContext()
|
|
104
|
+
await expect(buildCliContext()).rejects.toThrow('Invalid or inactive SPINE_CLI_API_KEY')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rejects when no active keys match the prefix', async () => {
|
|
108
|
+
mockLimit.mockResolvedValue({ data: [], error: null })
|
|
109
|
+
process.env.SPINE_CLI_API_KEY = 'sk_nomatch_00000'
|
|
110
|
+
|
|
111
|
+
const { buildCliContext } = await loadContext()
|
|
112
|
+
await expect(buildCliContext()).rejects.toThrow('Invalid or inactive SPINE_CLI_API_KEY')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const principalRef = vi.hoisted(() => ({ principal: null as any }))
|
|
4
|
+
|
|
5
|
+
vi.mock('../../functions/_shared/middleware', () => ({
|
|
6
|
+
createHandler: (fn: any) => async () =>
|
|
7
|
+
fn({ principal: principalRef.principal, requestId: 'req-1', accountId: 'acct-1' }, {}),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { handler } from '../../functions/debug-auth'
|
|
11
|
+
|
|
12
|
+
const event = (method?: string) => ({ queryStringParameters: method ? { method } : {} })
|
|
13
|
+
|
|
14
|
+
describe('debug-auth system_admin gate', () => {
|
|
15
|
+
it('denies a non-admin principal with statusCode 403', async () => {
|
|
16
|
+
principalRef.principal = { type: 'person', id: 'p1', roles: [{ slug: 'member' }] }
|
|
17
|
+
await expect(handler(event(), {})).rejects.toMatchObject({ statusCode: 403 })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('denies an unauthenticated request (no principal)', async () => {
|
|
21
|
+
principalRef.principal = null
|
|
22
|
+
await expect(handler(event(), {})).rejects.toMatchObject({ statusCode: 403 })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns debug data for a system_admin principal', async () => {
|
|
26
|
+
principalRef.principal = { type: 'person', id: 'p1', roles: [{ slug: 'system_admin' }] }
|
|
27
|
+
const res = await handler(event('debug'), {})
|
|
28
|
+
expect(res.data.message).toBe('Debug auth endpoint')
|
|
29
|
+
expect(res.data.hasPrincipal).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns tokenValid for testJwt with a system_admin principal', async () => {
|
|
33
|
+
principalRef.principal = { type: 'person', id: 'p1', roles: [{ slug: 'system_admin' }] }
|
|
34
|
+
const res = await handler(event('testJwt'), {})
|
|
35
|
+
expect(res.data.tokenValid).toBe(true)
|
|
36
|
+
expect(res.data.principal.id).toBe('p1')
|
|
37
|
+
})
|
|
38
|
+
})
|