@antasphere/chassis-server 1.0.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/LICENSE +82 -0
- package/dist/accounts/deletion.d.ts +163 -0
- package/dist/accounts/deletion.d.ts.map +1 -0
- package/dist/accounts/deletion.js +360 -0
- package/dist/accounts/deletion.js.map +1 -0
- package/dist/accounts/erasure-log.d.ts +66 -0
- package/dist/accounts/erasure-log.d.ts.map +1 -0
- package/dist/accounts/erasure-log.js +117 -0
- package/dist/accounts/erasure-log.js.map +1 -0
- package/dist/accounts/index.d.ts +6 -0
- package/dist/accounts/index.d.ts.map +1 -0
- package/dist/accounts/index.js +6 -0
- package/dist/accounts/index.js.map +1 -0
- package/dist/accounts/mint-refusal.d.ts +47 -0
- package/dist/accounts/mint-refusal.d.ts.map +1 -0
- package/dist/accounts/mint-refusal.js +59 -0
- package/dist/accounts/mint-refusal.js.map +1 -0
- package/dist/accounts/signup-duplicate.d.ts +14 -0
- package/dist/accounts/signup-duplicate.d.ts.map +1 -0
- package/dist/accounts/signup-duplicate.js +25 -0
- package/dist/accounts/signup-duplicate.js.map +1 -0
- package/dist/accounts/superadmin.d.ts +27 -0
- package/dist/accounts/superadmin.d.ts.map +1 -0
- package/dist/accounts/superadmin.js +46 -0
- package/dist/accounts/superadmin.js.map +1 -0
- package/dist/api/apikeys.d.ts +14 -0
- package/dist/api/apikeys.d.ts.map +1 -0
- package/dist/api/apikeys.js +124 -0
- package/dist/api/apikeys.js.map +1 -0
- package/dist/api/audit.d.ts +4 -0
- package/dist/api/audit.d.ts.map +1 -0
- package/dist/api/audit.js +75 -0
- package/dist/api/audit.js.map +1 -0
- package/dist/api/break-glass.d.ts +48 -0
- package/dist/api/break-glass.d.ts.map +1 -0
- package/dist/api/break-glass.js +217 -0
- package/dist/api/break-glass.js.map +1 -0
- package/dist/api/cli-auth.d.ts +84 -0
- package/dist/api/cli-auth.d.ts.map +1 -0
- package/dist/api/cli-auth.js +177 -0
- package/dist/api/cli-auth.js.map +1 -0
- package/dist/api/create-api.d.ts +123 -0
- package/dist/api/create-api.d.ts.map +1 -0
- package/dist/api/create-api.js +1165 -0
- package/dist/api/create-api.js.map +1 -0
- package/dist/api/demo-passes.d.ts +45 -0
- package/dist/api/demo-passes.d.ts.map +1 -0
- package/dist/api/demo-passes.js +143 -0
- package/dist/api/demo-passes.js.map +1 -0
- package/dist/api/errors.d.ts +5 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +5 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/export.d.ts +63 -0
- package/dist/api/export.d.ts.map +1 -0
- package/dist/api/export.js +393 -0
- package/dist/api/export.js.map +1 -0
- package/dist/api/files.d.ts +40 -0
- package/dist/api/files.d.ts.map +1 -0
- package/dist/api/files.js +150 -0
- package/dist/api/files.js.map +1 -0
- package/dist/api/index.d.ts +17 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +17 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/invitations.d.ts +50 -0
- package/dist/api/invitations.d.ts.map +1 -0
- package/dist/api/invitations.js +313 -0
- package/dist/api/invitations.js.map +1 -0
- package/dist/api/members.d.ts +25 -0
- package/dist/api/members.d.ts.map +1 -0
- package/dist/api/members.js +522 -0
- package/dist/api/members.js.map +1 -0
- package/dist/api/onboarding.d.ts +26 -0
- package/dist/api/onboarding.d.ts.map +1 -0
- package/dist/api/onboarding.js +23 -0
- package/dist/api/onboarding.js.map +1 -0
- package/dist/api/openapi-doc.d.ts +21 -0
- package/dist/api/openapi-doc.d.ts.map +1 -0
- package/dist/api/openapi-doc.js +25 -0
- package/dist/api/openapi-doc.js.map +1 -0
- package/dist/api/projects.d.ts +24 -0
- package/dist/api/projects.d.ts.map +1 -0
- package/dist/api/projects.js +624 -0
- package/dist/api/projects.js.map +1 -0
- package/dist/api/scope-routes.d.ts +12 -0
- package/dist/api/scope-routes.d.ts.map +1 -0
- package/dist/api/scope-routes.js +2 -0
- package/dist/api/scope-routes.js.map +1 -0
- package/dist/api/sso-connect.d.ts +70 -0
- package/dist/api/sso-connect.d.ts.map +1 -0
- package/dist/api/sso-connect.js +164 -0
- package/dist/api/sso-connect.js.map +1 -0
- package/dist/api/sso-logout.d.ts +41 -0
- package/dist/api/sso-logout.d.ts.map +1 -0
- package/dist/api/sso-logout.js +34 -0
- package/dist/api/sso-logout.js.map +1 -0
- package/dist/api/teams.d.ts +43 -0
- package/dist/api/teams.d.ts.map +1 -0
- package/dist/api/teams.js +447 -0
- package/dist/api/teams.js.map +1 -0
- package/dist/api/workspaces.d.ts +114 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +462 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/apikeys/index.d.ts +3 -0
- package/dist/apikeys/index.d.ts.map +1 -0
- package/dist/apikeys/index.js +3 -0
- package/dist/apikeys/index.js.map +1 -0
- package/dist/apikeys/peppers.d.ts +53 -0
- package/dist/apikeys/peppers.d.ts.map +1 -0
- package/dist/apikeys/peppers.js +97 -0
- package/dist/apikeys/peppers.js.map +1 -0
- package/dist/apikeys/service.d.ts +56 -0
- package/dist/apikeys/service.d.ts.map +1 -0
- package/dist/apikeys/service.js +196 -0
- package/dist/apikeys/service.js.map +1 -0
- package/dist/app.d.ts +65 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +142 -0
- package/dist/app.js.map +1 -0
- package/dist/audit/filters.d.ts +38 -0
- package/dist/audit/filters.d.ts.map +1 -0
- package/dist/audit/filters.js +97 -0
- package/dist/audit/filters.js.map +1 -0
- package/dist/audit/index.d.ts +3 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +3 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/service.d.ts +65 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +103 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/boot.d.ts +8 -0
- package/dist/boot.d.ts.map +1 -0
- package/dist/boot.js +822 -0
- package/dist/boot.js.map +1 -0
- package/dist/constant-time.d.ts +3 -0
- package/dist/constant-time.d.ts.map +1 -0
- package/dist/constant-time.js +10 -0
- package/dist/constant-time.js.map +1 -0
- package/dist/email/driver.d.ts +16 -0
- package/dist/email/driver.d.ts.map +1 -0
- package/dist/email/driver.js +136 -0
- package/dist/email/driver.js.map +1 -0
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +4 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/shell.d.ts +72 -0
- package/dist/email/shell.d.ts.map +1 -0
- package/dist/email/shell.js +146 -0
- package/dist/email/shell.js.map +1 -0
- package/dist/email/templates.d.ts +83 -0
- package/dist/email/templates.d.ts.map +1 -0
- package/dist/email/templates.js +96 -0
- package/dist/email/templates.js.map +1 -0
- package/dist/entitlements/check.d.ts +140 -0
- package/dist/entitlements/check.d.ts.map +1 -0
- package/dist/entitlements/check.js +276 -0
- package/dist/entitlements/check.js.map +1 -0
- package/dist/entitlements/clock-skew.d.ts +27 -0
- package/dist/entitlements/clock-skew.d.ts.map +1 -0
- package/dist/entitlements/clock-skew.js +58 -0
- package/dist/entitlements/clock-skew.js.map +1 -0
- package/dist/entitlements/gate.d.ts +113 -0
- package/dist/entitlements/gate.d.ts.map +1 -0
- package/dist/entitlements/gate.js +430 -0
- package/dist/entitlements/gate.js.map +1 -0
- package/dist/entitlements/hub-machine-token.d.ts +83 -0
- package/dist/entitlements/hub-machine-token.d.ts.map +1 -0
- package/dist/entitlements/hub-machine-token.js +175 -0
- package/dist/entitlements/hub-machine-token.js.map +1 -0
- package/dist/entitlements/hub-subject.d.ts +32 -0
- package/dist/entitlements/hub-subject.d.ts.map +1 -0
- package/dist/entitlements/hub-subject.js +68 -0
- package/dist/entitlements/hub-subject.js.map +1 -0
- package/dist/entitlements/index.d.ts +9 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +9 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/poster.d.ts +81 -0
- package/dist/entitlements/poster.d.ts.map +1 -0
- package/dist/entitlements/poster.js +169 -0
- package/dist/entitlements/poster.js.map +1 -0
- package/dist/entitlements/profiles.d.ts +121 -0
- package/dist/entitlements/profiles.d.ts.map +1 -0
- package/dist/entitlements/profiles.js +212 -0
- package/dist/entitlements/profiles.js.map +1 -0
- package/dist/entitlements/slot.d.ts +33 -0
- package/dist/entitlements/slot.d.ts.map +1 -0
- package/dist/entitlements/slot.js +75 -0
- package/dist/entitlements/slot.js.map +1 -0
- package/dist/env.d.ts +293 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +383 -0
- package/dist/env.js.map +1 -0
- package/dist/files/http.d.ts +23 -0
- package/dist/files/http.d.ts.map +1 -0
- package/dist/files/http.js +66 -0
- package/dist/files/http.js.map +1 -0
- package/dist/files/index.d.ts +5 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +5 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/serve.d.ts +43 -0
- package/dist/files/serve.d.ts.map +1 -0
- package/dist/files/serve.js +71 -0
- package/dist/files/serve.js.map +1 -0
- package/dist/files/service.d.ts +69 -0
- package/dist/files/service.d.ts.map +1 -0
- package/dist/files/service.js +188 -0
- package/dist/files/service.js.map +1 -0
- package/dist/files/spool.d.ts +21 -0
- package/dist/files/spool.d.ts.map +1 -0
- package/dist/files/spool.js +47 -0
- package/dist/files/spool.js.map +1 -0
- package/dist/identity/better-auth.d.ts +3927 -0
- package/dist/identity/better-auth.d.ts.map +1 -0
- package/dist/identity/better-auth.js +896 -0
- package/dist/identity/better-auth.js.map +1 -0
- package/dist/identity/demo-pass-plugin.d.ts +29 -0
- package/dist/identity/demo-pass-plugin.d.ts.map +1 -0
- package/dist/identity/demo-pass-plugin.js +88 -0
- package/dist/identity/demo-pass-plugin.js.map +1 -0
- package/dist/identity/demo-pass-rules.d.ts +84 -0
- package/dist/identity/demo-pass-rules.d.ts.map +1 -0
- package/dist/identity/demo-pass-rules.js +223 -0
- package/dist/identity/demo-pass-rules.js.map +1 -0
- package/dist/identity/demo-pass.d.ts +159 -0
- package/dist/identity/demo-pass.d.ts.map +1 -0
- package/dist/identity/demo-pass.js +319 -0
- package/dist/identity/demo-pass.js.map +1 -0
- package/dist/identity/hub-grant.d.ts +182 -0
- package/dist/identity/hub-grant.d.ts.map +1 -0
- package/dist/identity/hub-grant.js +594 -0
- package/dist/identity/hub-grant.js.map +1 -0
- package/dist/identity/hub-jwt.d.ts +22 -0
- package/dist/identity/hub-jwt.d.ts.map +1 -0
- package/dist/identity/hub-jwt.js +77 -0
- package/dist/identity/hub-jwt.js.map +1 -0
- package/dist/identity/hub-live-gate.d.ts +17 -0
- package/dist/identity/hub-live-gate.d.ts.map +1 -0
- package/dist/identity/hub-live-gate.js +146 -0
- package/dist/identity/hub-live-gate.js.map +1 -0
- package/dist/identity/hub-logout.d.ts +88 -0
- package/dist/identity/hub-logout.d.ts.map +1 -0
- package/dist/identity/hub-logout.js +158 -0
- package/dist/identity/hub-logout.js.map +1 -0
- package/dist/identity/hub-projection.d.ts +103 -0
- package/dist/identity/hub-projection.d.ts.map +1 -0
- package/dist/identity/hub-projection.js +130 -0
- package/dist/identity/hub-projection.js.map +1 -0
- package/dist/identity/hub-reconcile.d.ts +151 -0
- package/dist/identity/hub-reconcile.d.ts.map +1 -0
- package/dist/identity/hub-reconcile.js +400 -0
- package/dist/identity/hub-reconcile.js.map +1 -0
- package/dist/identity/hub-sso.d.ts +363 -0
- package/dist/identity/hub-sso.d.ts.map +1 -0
- package/dist/identity/hub-sso.js +536 -0
- package/dist/identity/hub-sso.js.map +1 -0
- package/dist/identity/hub-user-client.d.ts +222 -0
- package/dist/identity/hub-user-client.d.ts.map +1 -0
- package/dist/identity/hub-user-client.js +406 -0
- package/dist/identity/hub-user-client.js.map +1 -0
- package/dist/identity/index.d.ts +13 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +13 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/oauth-jwt.d.ts +32 -0
- package/dist/identity/oauth-jwt.d.ts.map +1 -0
- package/dist/identity/oauth-jwt.js +122 -0
- package/dist/identity/oauth-jwt.js.map +1 -0
- package/dist/identity/resolve-membership.d.ts +28 -0
- package/dist/identity/resolve-membership.d.ts.map +1 -0
- package/dist/identity/resolve-membership.js +69 -0
- package/dist/identity/resolve-membership.js.map +1 -0
- package/dist/identity/signing-key.d.ts +49 -0
- package/dist/identity/signing-key.d.ts.map +1 -0
- package/dist/identity/signing-key.js +144 -0
- package/dist/identity/signing-key.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/invitations/index.d.ts +2 -0
- package/dist/invitations/index.d.ts.map +1 -0
- package/dist/invitations/index.js +2 -0
- package/dist/invitations/index.js.map +1 -0
- package/dist/invitations/service.d.ts +36 -0
- package/dist/invitations/service.d.ts.map +1 -0
- package/dist/invitations/service.js +122 -0
- package/dist/invitations/service.js.map +1 -0
- package/dist/jobs/index.d.ts +3 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +3 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/pgboss.d.ts +119 -0
- package/dist/jobs/pgboss.d.ts.map +1 -0
- package/dist/jobs/pgboss.js +521 -0
- package/dist/jobs/pgboss.js.map +1 -0
- package/dist/jobs/timers.d.ts +127 -0
- package/dist/jobs/timers.d.ts.map +1 -0
- package/dist/jobs/timers.js +228 -0
- package/dist/jobs/timers.js.map +1 -0
- package/dist/logger.d.ts +17 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +64 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/errors.d.ts +37 -0
- package/dist/mcp/errors.d.ts.map +1 -0
- package/dist/mcp/errors.js +77 -0
- package/dist/mcp/errors.js.map +1 -0
- package/dist/mcp/http.d.ts +35 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +80 -0
- package/dist/mcp/http.js.map +1 -0
- package/dist/mcp/index.d.ts +8 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +8 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/inputs.d.ts +19 -0
- package/dist/mcp/inputs.d.ts.map +1 -0
- package/dist/mcp/inputs.js +28 -0
- package/dist/mcp/inputs.js.map +1 -0
- package/dist/mcp/projects.d.ts +31 -0
- package/dist/mcp/projects.d.ts.map +1 -0
- package/dist/mcp/projects.js +315 -0
- package/dist/mcp/projects.js.map +1 -0
- package/dist/mcp/server.d.ts +52 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +93 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/teams.d.ts +7 -0
- package/dist/mcp/teams.d.ts.map +1 -0
- package/dist/mcp/teams.js +62 -0
- package/dist/mcp/teams.js.map +1 -0
- package/dist/mcp/tool-kit.d.ts +67 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -0
- package/dist/mcp/tool-kit.js +74 -0
- package/dist/mcp/tool-kit.js.map +1 -0
- package/dist/members/removal.d.ts +95 -0
- package/dist/members/removal.d.ts.map +1 -0
- package/dist/members/removal.js +143 -0
- package/dist/members/removal.js.map +1 -0
- package/dist/middleware/auth-body.d.ts +25 -0
- package/dist/middleware/auth-body.d.ts.map +1 -0
- package/dist/middleware/auth-body.js +55 -0
- package/dist/middleware/auth-body.js.map +1 -0
- package/dist/middleware/auth-context.d.ts +170 -0
- package/dist/middleware/auth-context.d.ts.map +1 -0
- package/dist/middleware/auth-context.js +251 -0
- package/dist/middleware/auth-context.js.map +1 -0
- package/dist/middleware/body-cap.d.ts +17 -0
- package/dist/middleware/body-cap.d.ts.map +1 -0
- package/dist/middleware/body-cap.js +31 -0
- package/dist/middleware/body-cap.js.map +1 -0
- package/dist/middleware/body-refusal.d.ts +20 -0
- package/dist/middleware/body-refusal.d.ts.map +1 -0
- package/dist/middleware/body-refusal.js +5 -0
- package/dist/middleware/body-refusal.js.map +1 -0
- package/dist/middleware/cross-site.d.ts +61 -0
- package/dist/middleware/cross-site.d.ts.map +1 -0
- package/dist/middleware/cross-site.js +59 -0
- package/dist/middleware/cross-site.js.map +1 -0
- package/dist/middleware/hub-managed.d.ts +45 -0
- package/dist/middleware/hub-managed.d.ts.map +1 -0
- package/dist/middleware/hub-managed.js +56 -0
- package/dist/middleware/hub-managed.js.map +1 -0
- package/dist/middleware/idempotency.d.ts +14 -0
- package/dist/middleware/idempotency.d.ts.map +1 -0
- package/dist/middleware/idempotency.js +222 -0
- package/dist/middleware/idempotency.js.map +1 -0
- package/dist/middleware/index.d.ts +12 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +12 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/json-depth.d.ts +32 -0
- package/dist/middleware/json-depth.d.ts.map +1 -0
- package/dist/middleware/json-depth.js +80 -0
- package/dist/middleware/json-depth.js.map +1 -0
- package/dist/middleware/no-store.d.ts +21 -0
- package/dist/middleware/no-store.d.ts.map +1 -0
- package/dist/middleware/no-store.js +32 -0
- package/dist/middleware/no-store.js.map +1 -0
- package/dist/middleware/oauth-public.d.ts +16 -0
- package/dist/middleware/oauth-public.d.ts.map +1 -0
- package/dist/middleware/oauth-public.js +50 -0
- package/dist/middleware/oauth-public.js.map +1 -0
- package/dist/middleware/rate-limit.d.ts +182 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +277 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/middleware/request-id.d.ts +10 -0
- package/dist/middleware/request-id.d.ts.map +1 -0
- package/dist/middleware/request-id.js +42 -0
- package/dist/middleware/request-id.js.map +1 -0
- package/dist/middleware/scopes.d.ts +33 -0
- package/dist/middleware/scopes.d.ts.map +1 -0
- package/dist/middleware/scopes.js +139 -0
- package/dist/middleware/scopes.js.map +1 -0
- package/dist/middleware/security-headers.d.ts +53 -0
- package/dist/middleware/security-headers.d.ts.map +1 -0
- package/dist/middleware/security-headers.js +96 -0
- package/dist/middleware/security-headers.js.map +1 -0
- package/dist/observability/index.d.ts +3 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +3 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/metrics.d.ts +17 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +82 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.d.ts.map +1 -0
- package/dist/observability/otel.js +54 -0
- package/dist/observability/otel.js.map +1 -0
- package/dist/pagination.d.ts +57 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +90 -0
- package/dist/pagination.js.map +1 -0
- package/dist/platform/edition.d.ts +57 -0
- package/dist/platform/edition.d.ts.map +1 -0
- package/dist/platform/edition.js +90 -0
- package/dist/platform/edition.js.map +1 -0
- package/dist/platform/entitlements.d.ts +26 -0
- package/dist/platform/entitlements.d.ts.map +1 -0
- package/dist/platform/entitlements.js +28 -0
- package/dist/platform/entitlements.js.map +1 -0
- package/dist/platform/events.d.ts +87 -0
- package/dist/platform/events.d.ts.map +1 -0
- package/dist/platform/events.js +32 -0
- package/dist/platform/events.js.map +1 -0
- package/dist/platform/index.d.ts +8 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +8 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/local-identity.d.ts +39 -0
- package/dist/platform/local-identity.d.ts.map +1 -0
- package/dist/platform/local-identity.js +87 -0
- package/dist/platform/local-identity.js.map +1 -0
- package/dist/platform/registry.d.ts +23 -0
- package/dist/platform/registry.d.ts.map +1 -0
- package/dist/platform/registry.js +5 -0
- package/dist/platform/registry.js.map +1 -0
- package/dist/platform/usage.d.ts +11 -0
- package/dist/platform/usage.d.ts.map +1 -0
- package/dist/platform/usage.js +12 -0
- package/dist/platform/usage.js.map +1 -0
- package/dist/platform/workspaces.d.ts +25 -0
- package/dist/platform/workspaces.d.ts.map +1 -0
- package/dist/platform/workspaces.js +44 -0
- package/dist/platform/workspaces.js.map +1 -0
- package/dist/projects/access.d.ts +40 -0
- package/dist/projects/access.d.ts.map +1 -0
- package/dist/projects/access.js +133 -0
- package/dist/projects/access.js.map +1 -0
- package/dist/projects/index.d.ts +2 -0
- package/dist/projects/index.d.ts.map +1 -0
- package/dist/projects/index.js +2 -0
- package/dist/projects/index.js.map +1 -0
- package/dist/route-label.d.ts +23 -0
- package/dist/route-label.d.ts.map +1 -0
- package/dist/route-label.js +29 -0
- package/dist/route-label.js.map +1 -0
- package/dist/routes/health.d.ts +30 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +54 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +3 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/wellknown.d.ts +11 -0
- package/dist/routes/wellknown.d.ts.map +1 -0
- package/dist/routes/wellknown.js +45 -0
- package/dist/routes/wellknown.js.map +1 -0
- package/dist/secret.d.ts +30 -0
- package/dist/secret.d.ts.map +1 -0
- package/dist/secret.js +97 -0
- package/dist/secret.js.map +1 -0
- package/dist/setup-transport.d.ts +23 -0
- package/dist/setup-transport.d.ts.map +1 -0
- package/dist/setup-transport.js +52 -0
- package/dist/setup-transport.js.map +1 -0
- package/dist/state.d.ts +21 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +4 -0
- package/dist/state.js.map +1 -0
- package/dist/storage/driver.d.ts +38 -0
- package/dist/storage/driver.d.ts.map +1 -0
- package/dist/storage/driver.js +14 -0
- package/dist/storage/driver.js.map +1 -0
- package/dist/storage/factory.d.ts +4 -0
- package/dist/storage/factory.d.ts.map +1 -0
- package/dist/storage/factory.js +20 -0
- package/dist/storage/factory.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +4 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/local.d.ts +21 -0
- package/dist/storage/local.d.ts.map +1 -0
- package/dist/storage/local.js +88 -0
- package/dist/storage/local.js.map +1 -0
- package/dist/storage/s3.d.ts +29 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +82 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/testing/fake-hub.d.ts +425 -0
- package/dist/testing/fake-hub.d.ts.map +1 -0
- package/dist/testing/fake-hub.js +1074 -0
- package/dist/testing/fake-hub.js.map +1 -0
- package/dist/testing/helpers.d.ts +124 -0
- package/dist/testing/helpers.d.ts.map +1 -0
- package/dist/testing/helpers.js +174 -0
- package/dist/testing/helpers.js.map +1 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/setup-origins.d.ts +21 -0
- package/dist/testing/setup-origins.d.ts.map +1 -0
- package/dist/testing/setup-origins.js +57 -0
- package/dist/testing/setup-origins.js.map +1 -0
- package/dist/testing/sso-helpers.d.ts +43 -0
- package/dist/testing/sso-helpers.d.ts.map +1 -0
- package/dist/testing/sso-helpers.js +159 -0
- package/dist/testing/sso-helpers.js.map +1 -0
- package/dist/tool-definition.d.ts +382 -0
- package/dist/tool-definition.d.ts.map +1 -0
- package/dist/tool-definition.js +46 -0
- package/dist/tool-definition.js.map +1 -0
- package/dist/util/index.d.ts +8 -0
- package/dist/util/index.d.ts.map +1 -0
- package/dist/util/index.js +8 -0
- package/dist/util/index.js.map +1 -0
- package/package.json +161 -0
- package/src/accounts/deletion.ts +389 -0
- package/src/accounts/erasure-log.ts +140 -0
- package/src/accounts/index.ts +5 -0
- package/src/accounts/mint-refusal.ts +67 -0
- package/src/accounts/signup-duplicate.ts +21 -0
- package/src/accounts/superadmin.ts +46 -0
- package/src/api/apikeys.ts +147 -0
- package/src/api/audit.ts +83 -0
- package/src/api/break-glass.ts +294 -0
- package/src/api/cli-auth.ts +308 -0
- package/src/api/create-api.ts +1403 -0
- package/src/api/demo-passes.ts +207 -0
- package/src/api/errors.ts +13 -0
- package/src/api/export.ts +482 -0
- package/src/api/files.ts +200 -0
- package/src/api/index.ts +29 -0
- package/src/api/invitations.ts +396 -0
- package/src/api/members.ts +617 -0
- package/src/api/onboarding.ts +50 -0
- package/src/api/openapi-doc.ts +34 -0
- package/src/api/projects.ts +788 -0
- package/src/api/scope-routes.ts +12 -0
- package/src/api/sso-connect.ts +246 -0
- package/src/api/sso-logout.ts +76 -0
- package/src/api/teams.ts +575 -0
- package/src/api/workspaces.ts +649 -0
- package/src/apikeys/index.ts +2 -0
- package/src/apikeys/peppers.ts +118 -0
- package/src/apikeys/service.ts +223 -0
- package/src/app.ts +228 -0
- package/src/audit/filters.ts +101 -0
- package/src/audit/index.ts +9 -0
- package/src/audit/service.ts +154 -0
- package/src/boot.ts +953 -0
- package/src/constant-time.ts +9 -0
- package/src/email/driver.ts +139 -0
- package/src/email/index.ts +26 -0
- package/src/email/shell.ts +201 -0
- package/src/email/templates.ts +184 -0
- package/src/entitlements/check.ts +435 -0
- package/src/entitlements/clock-skew.ts +58 -0
- package/src/entitlements/gate.ts +636 -0
- package/src/entitlements/hub-machine-token.ts +205 -0
- package/src/entitlements/hub-subject.ts +77 -0
- package/src/entitlements/index.ts +37 -0
- package/src/entitlements/poster.ts +265 -0
- package/src/entitlements/profiles.ts +303 -0
- package/src/entitlements/slot.ts +105 -0
- package/src/env.ts +456 -0
- package/src/files/http.ts +77 -0
- package/src/files/index.ts +4 -0
- package/src/files/serve.ts +115 -0
- package/src/files/service.ts +209 -0
- package/src/files/spool.ts +58 -0
- package/src/identity/better-auth.ts +1103 -0
- package/src/identity/demo-pass-plugin.ts +121 -0
- package/src/identity/demo-pass-rules.ts +229 -0
- package/src/identity/demo-pass.ts +390 -0
- package/src/identity/hub-grant.ts +704 -0
- package/src/identity/hub-jwt.ts +78 -0
- package/src/identity/hub-live-gate.ts +167 -0
- package/src/identity/hub-logout.ts +174 -0
- package/src/identity/hub-projection.ts +251 -0
- package/src/identity/hub-reconcile.ts +572 -0
- package/src/identity/hub-sso.ts +696 -0
- package/src/identity/hub-user-client.ts +554 -0
- package/src/identity/index.ts +56 -0
- package/src/identity/oauth-jwt.ts +128 -0
- package/src/identity/resolve-membership.ts +104 -0
- package/src/identity/signing-key.ts +164 -0
- package/src/index.ts +65 -0
- package/src/invitations/index.ts +1 -0
- package/src/invitations/service.ts +171 -0
- package/src/jobs/index.ts +25 -0
- package/src/jobs/pgboss.ts +594 -0
- package/src/jobs/timers.ts +287 -0
- package/src/logger.ts +67 -0
- package/src/mcp/errors.ts +111 -0
- package/src/mcp/http.ts +129 -0
- package/src/mcp/index.ts +28 -0
- package/src/mcp/inputs.ts +33 -0
- package/src/mcp/projects.ts +480 -0
- package/src/mcp/server.ts +174 -0
- package/src/mcp/teams.ts +97 -0
- package/src/mcp/tool-kit.ts +148 -0
- package/src/members/removal.ts +216 -0
- package/src/middleware/auth-body.ts +58 -0
- package/src/middleware/auth-context.ts +384 -0
- package/src/middleware/body-cap.ts +33 -0
- package/src/middleware/body-refusal.ts +23 -0
- package/src/middleware/cross-site.ts +122 -0
- package/src/middleware/hub-managed.ts +61 -0
- package/src/middleware/idempotency.ts +264 -0
- package/src/middleware/index.ts +33 -0
- package/src/middleware/json-depth.ts +74 -0
- package/src/middleware/no-store.ts +30 -0
- package/src/middleware/oauth-public.ts +54 -0
- package/src/middleware/rate-limit.ts +432 -0
- package/src/middleware/request-id.ts +54 -0
- package/src/middleware/scopes.ts +150 -0
- package/src/middleware/security-headers.ts +129 -0
- package/src/observability/index.ts +2 -0
- package/src/observability/metrics.ts +102 -0
- package/src/observability/otel.ts +79 -0
- package/src/pagination.ts +118 -0
- package/src/platform/edition.ts +140 -0
- package/src/platform/entitlements.ts +47 -0
- package/src/platform/events.ts +71 -0
- package/src/platform/index.ts +7 -0
- package/src/platform/local-identity.ts +90 -0
- package/src/platform/registry.ts +28 -0
- package/src/platform/usage.ts +13 -0
- package/src/platform/workspaces.ts +41 -0
- package/src/projects/access.ts +160 -0
- package/src/projects/index.ts +7 -0
- package/src/route-label.ts +29 -0
- package/src/routes/health.ts +79 -0
- package/src/routes/index.ts +2 -0
- package/src/routes/wellknown.ts +67 -0
- package/src/secret.ts +115 -0
- package/src/setup-transport.ts +46 -0
- package/src/state.ts +23 -0
- package/src/storage/driver.ts +48 -0
- package/src/storage/factory.ts +34 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/local.ts +93 -0
- package/src/storage/s3.ts +109 -0
- package/src/testing/fake-hub.ts +1316 -0
- package/src/testing/helpers.ts +254 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/setup-origins.ts +58 -0
- package/src/testing/sso-helpers.ts +178 -0
- package/src/tool-definition.ts +463 -0
- package/src/util/index.ts +17 -0
- package/test/fixtures/onboarding-backfill.sql +13 -0
- package/test/fixtures/operator-user-id-backfill.sql +18 -0
- package/test/host/dr-lib.sh +253 -0
- package/test/host/drizzle/0000_chassis_baseline.sql +438 -0
- package/test/host/drizzle/0001_last_owner_guard.sql +42 -0
- package/test/host/drizzle/meta/0000_snapshot.json +3566 -0
- package/test/host/drizzle/meta/_journal.json +20 -0
- package/test/host/drizzle.config.ts +17 -0
- package/test/host/identity.ts +41 -0
- package/test/host/minimal-host.ts +35 -0
- package/test/host/minimal-tool.ts +123 -0
- package/test/integration/account-oracle-uniformity.test.ts +284 -0
- package/test/integration/account-recovery.test.ts +236 -0
- package/test/integration/account.test.ts +120 -0
- package/test/integration/api-quota.test.ts +208 -0
- package/test/integration/apikey-expiry.test.ts +178 -0
- package/test/integration/apikey-pepper-rotation.test.ts +190 -0
- package/test/integration/boot-posture.test.ts +169 -0
- package/test/integration/break-glass.test.ts +484 -0
- package/test/integration/cloud-lifecycle.test.ts +233 -0
- package/test/integration/credential-boundaries.test.ts +195 -0
- package/test/integration/default-workspace.test.ts +414 -0
- package/test/integration/demo-pass-rules.test.ts +332 -0
- package/test/integration/demo-pass.test.ts +1222 -0
- package/test/integration/dr-verify-real-dump.test.ts +154 -0
- package/test/integration/edition.test.ts +868 -0
- package/test/integration/email-change.test.ts +394 -0
- package/test/integration/empty-tool.test.ts +267 -0
- package/test/integration/entitlements-null-hook.test.ts +187 -0
- package/test/integration/entitlements.test.ts +693 -0
- package/test/integration/export-entries.test.ts +321 -0
- package/test/integration/files.test.ts +320 -0
- package/test/integration/first-boot-claim.test.ts +131 -0
- package/test/integration/gdpr.test.ts +532 -0
- package/test/integration/helpers.ts +29 -0
- package/test/integration/hub-grant.test.ts +505 -0
- package/test/integration/hub-live-gate.test.ts +612 -0
- package/test/integration/hub-managed-membership.test.ts +559 -0
- package/test/integration/hub-org-selector.test.ts +281 -0
- package/test/integration/hub-reconcile.test.ts +646 -0
- package/test/integration/hub-sso.test.ts +704 -0
- package/test/integration/hub-teams.test.ts +771 -0
- package/test/integration/idempotency.test.ts +261 -0
- package/test/integration/invite-verification.test.ts +210 -0
- package/test/integration/last-owner-race.test.ts +263 -0
- package/test/integration/m9-fixes.test.ts +290 -0
- package/test/integration/mcp-projects.test.ts +463 -0
- package/test/integration/member-removal-race.test.ts +466 -0
- package/test/integration/member-removal.test.ts +794 -0
- package/test/integration/oauth-mcp.test.ts +477 -0
- package/test/integration/oauth-workspace.test.ts +371 -0
- package/test/integration/observability.test.ts +183 -0
- package/test/integration/onboarding.test.ts +268 -0
- package/test/integration/orphan-purge.test.ts +451 -0
- package/test/integration/pagination.test.ts +344 -0
- package/test/integration/platform-core.test.ts +305 -0
- package/test/integration/platform.test.ts +246 -0
- package/test/integration/pool-survives-restart.test.ts +80 -0
- package/test/integration/projects-cloud.test.ts +193 -0
- package/test/integration/projects.test.ts +1310 -0
- package/test/integration/security-fixes.test.ts +190 -0
- package/test/integration/setup-transport.test.ts +118 -0
- package/test/integration/signin-wall.test.ts +103 -0
- package/test/integration/signing-key-rotation.test.ts +349 -0
- package/test/integration/sso-helpers.ts +15 -0
- package/test/integration/sso-logout.test.ts +283 -0
- package/test/integration/teams-cloud.test.ts +194 -0
- package/test/integration/teams.test.ts +551 -0
- package/test/integration/timers.test.ts +377 -0
- package/test/integration/two-factor.test.ts +301 -0
- package/test/integration/usage-window.test.ts +161 -0
- package/test/integration/workspace-create-cloud.test.ts +523 -0
- package/test/integration/workspace-create.test.ts +510 -0
- package/test/integration/workspace-scoping.test.ts +530 -0
- package/test/integration/zz-adv-replay-owner.test.ts +205 -0
- package/test/unit/apikey-peppers.test.ts +110 -0
- package/test/unit/apikey-prefix.test.ts +34 -0
- package/test/unit/audit-filters.test.ts +135 -0
- package/test/unit/auth-origins.test.ts +92 -0
- package/test/unit/body-cap.test.ts +84 -0
- package/test/unit/clock-skew.test.ts +68 -0
- package/test/unit/cross-site.test.ts +166 -0
- package/test/unit/declared-credits.test.ts +13 -0
- package/test/unit/demo-pass-rules.test.ts +192 -0
- package/test/unit/email-driver.test.ts +127 -0
- package/test/unit/end-pool.test.ts +181 -0
- package/test/unit/entitlement-check.test.ts +492 -0
- package/test/unit/entitlement-gate.test.ts +1333 -0
- package/test/unit/entitlement-profiles.test.ts +289 -0
- package/test/unit/erasure-log.test.ts +72 -0
- package/test/unit/error-mapping.test.ts +89 -0
- package/test/unit/export-entries.test.ts +85 -0
- package/test/unit/export-sanitize.test.ts +27 -0
- package/test/unit/files-head-route.test.ts +155 -0
- package/test/unit/files-http.test.ts +97 -0
- package/test/unit/head-rewrite.test.ts +72 -0
- package/test/unit/hub-create-org.test.ts +181 -0
- package/test/unit/hub-jwt.test.ts +93 -0
- package/test/unit/hub-sso-params.test.ts +39 -0
- package/test/unit/hub-subject.test.ts +83 -0
- package/test/unit/idempotency-crypto.test.ts +45 -0
- package/test/unit/json-depth.test.ts +88 -0
- package/test/unit/logger-redaction.test.ts +66 -0
- package/test/unit/mcp-errors.test.ts +110 -0
- package/test/unit/mcp-inputs.test.ts +106 -0
- package/test/unit/mcp-project-hints.test.ts +26 -0
- package/test/unit/no-store.test.ts +49 -0
- package/test/unit/no-tool-migration-filename.test.ts +70 -0
- package/test/unit/observability.test.ts +64 -0
- package/test/unit/openapi-doc.test.ts +93 -0
- package/test/unit/rate-limit-consume-on.test.ts +76 -0
- package/test/unit/rate-limit.test.ts +39 -0
- package/test/unit/readyz-storage.test.ts +157 -0
- package/test/unit/request-quota.test.ts +146 -0
- package/test/unit/scopes.test.ts +66 -0
- package/test/unit/security-headers.test.ts +131 -0
- package/test/unit/serve-content-type.test.ts +74 -0
- package/test/unit/setup-transport.test.ts +29 -0
- package/test/unit/storage-local.test.ts +53 -0
- package/test/unit/superadmin.test.ts +32 -0
- package/test/unit/timers.test.ts +287 -0
- package/test/unit/tool-definition-guards.test.ts +166 -0
- package/test/unit/tool-entitlements-guard.test.ts +168 -0
- package/test/unit/usage-poster.test.ts +542 -0
- package/test/unit/usage-window.test.ts +48 -0
- package/test/unit/workspace-create-audit.test.ts +48 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
4
|
+
import { workspaceMembers } from '@antasphere/chassis-db';
|
|
5
|
+
import { FakeHub } from '@antasphere/chassis-server/testing';
|
|
6
|
+
import {
|
|
7
|
+
createDatabase,
|
|
8
|
+
createTestApp,
|
|
9
|
+
expectBootRefusal,
|
|
10
|
+
extractCookie,
|
|
11
|
+
readJson,
|
|
12
|
+
startPostgres,
|
|
13
|
+
type TestApp,
|
|
14
|
+
host
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Demo sign-in (the demo pass spec): an owner mints a link that signs one
|
|
19
|
+
* member in without a password. The link is sign-in-equivalent, so this suite
|
|
20
|
+
* pins every boundary around it:
|
|
21
|
+
*
|
|
22
|
+
* the switch → off, every demo path is an unknown path, signed in or
|
|
23
|
+
* not; on, only on a loopback or listed host; on cloud,
|
|
24
|
+
* nothing at all
|
|
25
|
+
* the mint → owner and session only, the refusals in the spec's
|
|
26
|
+
* order, the secret stored as its sha256 and nowhere else
|
|
27
|
+
* the redeem → the library's own session and cookie, AS THE MEMBER;
|
|
28
|
+
* one answer for every refusal; Origin required; a wall
|
|
29
|
+
* of 20 per address
|
|
30
|
+
* the trail → mint, use and revoke rows with the right actor, no
|
|
31
|
+
* secret anywhere, and a mutation made through a pass's
|
|
32
|
+
* session marked with the pass
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const PASSWORD = 'a-long-demo-pass-password-1';
|
|
36
|
+
const ORIGIN = 'http://localhost:3000';
|
|
37
|
+
const OWNER = { email: 'owner@example.com', name: 'Olive Owner', password: PASSWORD };
|
|
38
|
+
const DEMO_ON = { DEMO_SIGN_IN: 'true' };
|
|
39
|
+
|
|
40
|
+
type Actor = { email: string; cookie: string; userId: string };
|
|
41
|
+
|
|
42
|
+
let container: StartedPostgreSqlContainer;
|
|
43
|
+
let connectionString = '';
|
|
44
|
+
let app: TestApp;
|
|
45
|
+
let workspaceId = '';
|
|
46
|
+
const actors: Record<string, Actor> = {};
|
|
47
|
+
/** Every secret this suite minted: none may ever appear in an audit row. */
|
|
48
|
+
const secrets: string[] = [];
|
|
49
|
+
|
|
50
|
+
let ipCounter = 0;
|
|
51
|
+
const nextIp = () => `10.88.${Math.floor(ipCounter / 250)}.${(ipCounter++ % 250) + 1}`;
|
|
52
|
+
|
|
53
|
+
const sha256 = (value: string) => createHash('sha256').update(value, 'utf8').digest('hex');
|
|
54
|
+
|
|
55
|
+
const send = (
|
|
56
|
+
target: TestApp,
|
|
57
|
+
method: string,
|
|
58
|
+
path: string,
|
|
59
|
+
who: { cookie?: string; key?: string } = {},
|
|
60
|
+
body?: unknown,
|
|
61
|
+
headers: Record<string, string> = {}
|
|
62
|
+
) =>
|
|
63
|
+
target.app.request(`/api/v1${path}`, {
|
|
64
|
+
method,
|
|
65
|
+
headers: {
|
|
66
|
+
'x-forwarded-for': nextIp(),
|
|
67
|
+
origin: ORIGIN,
|
|
68
|
+
...(who.cookie ? { cookie: who.cookie } : {}),
|
|
69
|
+
...(who.key ? { authorization: `Bearer ${who.key}` } : {}),
|
|
70
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
71
|
+
...headers
|
|
72
|
+
},
|
|
73
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function expectError(res: Response, status: number, code: string): Promise<void> {
|
|
77
|
+
const body = await readJson(res);
|
|
78
|
+
expect({ status: res.status, code: body?.error?.code }).toEqual({ status, code });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function signIn(target: TestApp, email: string): Promise<string> {
|
|
82
|
+
const res = await send(target, 'POST', '/auth/sign-in/email', {}, { email, password: PASSWORD });
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
return extractCookie(res);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function addMember(
|
|
88
|
+
name: string,
|
|
89
|
+
email: string,
|
|
90
|
+
opts: { role?: 'owner' | 'admin' | 'member'; origin?: 'local' | 'guest'; signIn?: boolean } = {}
|
|
91
|
+
): Promise<Actor> {
|
|
92
|
+
const created = await app.auth.api.signUpEmail({ body: { email, password: PASSWORD, name } });
|
|
93
|
+
await app.db.db.insert(workspaceMembers).values({
|
|
94
|
+
workspaceId,
|
|
95
|
+
userId: created.user.id,
|
|
96
|
+
role: opts.role ?? 'member',
|
|
97
|
+
origin: opts.origin ?? 'local'
|
|
98
|
+
});
|
|
99
|
+
const actor = {
|
|
100
|
+
email,
|
|
101
|
+
cookie: opts.signIn === false ? '' : await signIn(app, email),
|
|
102
|
+
userId: created.user.id
|
|
103
|
+
};
|
|
104
|
+
actors[name] = actor;
|
|
105
|
+
return actor;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function mint(
|
|
109
|
+
who: Actor,
|
|
110
|
+
body: { email: string; path?: string; expiresInMinutes?: number },
|
|
111
|
+
target: TestApp = app
|
|
112
|
+
): Promise<{ id: string; secret: string; url: string; pass: Record<string, unknown> }> {
|
|
113
|
+
const res = await send(target, 'POST', '/demo/passes', who, body);
|
|
114
|
+
expect(res.status).toBe(201);
|
|
115
|
+
const minted = await readJson(res);
|
|
116
|
+
secrets.push(minted.secret);
|
|
117
|
+
return { id: minted.pass.id, secret: minted.secret, url: minted.url, pass: minted.pass };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const redeem = (secret: string, target: TestApp = app, headers: Record<string, string> = {}) =>
|
|
121
|
+
send(target, 'POST', '/auth/demo/redeem', {}, { pass: secret }, headers);
|
|
122
|
+
|
|
123
|
+
/** True when a response sets the session cookie (any value, any attributes). */
|
|
124
|
+
const setsSession = (res: Response) => (res.headers.get('set-cookie') ?? '').includes('session_token');
|
|
125
|
+
|
|
126
|
+
/** Status and body of an answer, the request id apart (it is a header, never a body field). */
|
|
127
|
+
async function answerOf(res: Response): Promise<{ status: number; body: string }> {
|
|
128
|
+
return { status: res.status, body: await res.text() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
beforeAll(async () => {
|
|
132
|
+
container = await startPostgres();
|
|
133
|
+
connectionString = await createDatabase(container, 'demo_pass');
|
|
134
|
+
app = await createTestApp(connectionString, DEMO_ON);
|
|
135
|
+
const setup = await send(
|
|
136
|
+
app,
|
|
137
|
+
'POST',
|
|
138
|
+
'/setup',
|
|
139
|
+
{},
|
|
140
|
+
{ setupToken: 'integration-test-setup-token', instanceName: 'Demo', owner: OWNER }
|
|
141
|
+
);
|
|
142
|
+
expect(setup.status).toBe(201);
|
|
143
|
+
workspaceId = (await readJson(setup)).workspaceId;
|
|
144
|
+
const ownerCookie = await signIn(app, OWNER.email);
|
|
145
|
+
const me = await readJson(await send(app, 'GET', '/me', { cookie: ownerCookie }));
|
|
146
|
+
actors.owner = { email: OWNER.email, cookie: ownerCookie, userId: me.user.id };
|
|
147
|
+
|
|
148
|
+
await addMember('member', 'member@example.com');
|
|
149
|
+
await addMember('admin', 'admin@example.com', { role: 'admin' });
|
|
150
|
+
await addMember('otherOwner', 'other-owner@example.com', { role: 'owner' });
|
|
151
|
+
await addMember('gmail', 'someone@gmail.com');
|
|
152
|
+
await addMember('twoFactor', 'two-factor@example.com');
|
|
153
|
+
await addMember('twoFactorGmail', 'two-factor@gmail.com');
|
|
154
|
+
await addMember('guest', 'guest@example.org', { origin: 'guest' });
|
|
155
|
+
await addMember('cross', 'cross@team.test');
|
|
156
|
+
await app.db.pool.query(`UPDATE "user" SET two_factor_enabled = true WHERE email LIKE 'two-factor@%'`);
|
|
157
|
+
// `cross` also owns a second workspace: a pass for them would carry into it.
|
|
158
|
+
const second = await send(app, 'POST', '/workspaces', actors.cross!, { name: 'Elsewhere' });
|
|
159
|
+
expect(second.status).toBe(201);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterAll(async () => {
|
|
163
|
+
await app?.stop();
|
|
164
|
+
await container?.stop();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
vi.restoreAllMocks();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('the switch', () => {
|
|
172
|
+
it('a. off: every demo path answers what an unknown path answers, signed out and signed in; /instance has no demoSignIn', async () => {
|
|
173
|
+
const off = await createTestApp(connectionString);
|
|
174
|
+
try {
|
|
175
|
+
const ownerCookie = await signIn(off, OWNER.email);
|
|
176
|
+
for (const who of [{}, { cookie: ownerCookie }]) {
|
|
177
|
+
const unknownApi = await answerOf(
|
|
178
|
+
await send(off, 'POST', '/definitely-not-a-route', who, { email: 'member@example.com' })
|
|
179
|
+
);
|
|
180
|
+
const unknownAuth = await answerOf(
|
|
181
|
+
await send(off, 'POST', '/auth/definitely-not-a-route', who, { pass: 'x' })
|
|
182
|
+
);
|
|
183
|
+
expect(
|
|
184
|
+
await answerOf(await send(off, 'POST', '/demo/passes', who, { email: 'member@example.com' }))
|
|
185
|
+
).toEqual(unknownApi);
|
|
186
|
+
expect(await answerOf(await send(off, 'GET', '/demo/passes', who))).toEqual(unknownApi);
|
|
187
|
+
expect(
|
|
188
|
+
await answerOf(await send(off, 'DELETE', '/demo/passes/11111111-1111-4111-8111-111111111111', who))
|
|
189
|
+
).toEqual(unknownApi);
|
|
190
|
+
expect(await answerOf(await redeem('x', off, who.cookie ? { cookie: who.cookie } : {}))).toEqual(
|
|
191
|
+
unknownAuth
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const instance = await readJson(await send(off, 'GET', '/instance'));
|
|
195
|
+
expect('demoSignIn' in instance).toBe(false);
|
|
196
|
+
// …and on, the key is there.
|
|
197
|
+
expect((await readJson(await send(app, 'GET', '/instance'))).demoSignIn).toBe(true);
|
|
198
|
+
} finally {
|
|
199
|
+
await off.stop();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('b. the boot refuses the switch on a public host nobody listed, and accepts loopback or a listed host', async () => {
|
|
204
|
+
// The env parser's refusal is a printed table and process.exit(1): both
|
|
205
|
+
// are stubbed so the refusal surfaces as a rejection carrying the table.
|
|
206
|
+
let printed = '';
|
|
207
|
+
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
208
|
+
printed += args.map(String).join(' ');
|
|
209
|
+
});
|
|
210
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
211
|
+
throw new Error(`process.exit(${String(code)}): ${printed}`);
|
|
212
|
+
});
|
|
213
|
+
await expectBootRefusal(
|
|
214
|
+
createTestApp(connectionString, { ...DEMO_ON, PUBLIC_BASE_URL: 'https://tool.example.io' }),
|
|
215
|
+
/DEMO_SIGN_IN\s+DEMO_SIGN_IN is refused on tool\.example\.io/
|
|
216
|
+
);
|
|
217
|
+
printed = '';
|
|
218
|
+
await expectBootRefusal(
|
|
219
|
+
createTestApp(connectionString, { ...DEMO_ON, PUBLIC_BASE_URL: 'https://localhost.evil.test' }),
|
|
220
|
+
/DEMO_SIGN_IN is refused on localhost\.evil\.test/
|
|
221
|
+
);
|
|
222
|
+
vi.restoreAllMocks();
|
|
223
|
+
|
|
224
|
+
const listed = await createTestApp(connectionString, {
|
|
225
|
+
...DEMO_ON,
|
|
226
|
+
PUBLIC_BASE_URL: 'https://tool.example.io',
|
|
227
|
+
DEMO_SIGN_IN_HOSTS: 'tool.example.io'
|
|
228
|
+
});
|
|
229
|
+
await listed.stop();
|
|
230
|
+
const subdomain = await createTestApp(connectionString, {
|
|
231
|
+
...DEMO_ON,
|
|
232
|
+
PUBLIC_BASE_URL: 'http://app.localhost:3000'
|
|
233
|
+
});
|
|
234
|
+
await subdomain.stop();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('l. cloud: with the switch on, every demo path is an unknown path and /instance has no demoSignIn', async () => {
|
|
238
|
+
const hub = await FakeHub.start({ clientId: host.hubClientId });
|
|
239
|
+
const cloud = await createTestApp(await createDatabase(container, 'demo_pass_cloud'), {
|
|
240
|
+
...DEMO_ON,
|
|
241
|
+
EDITION: 'cloud',
|
|
242
|
+
HUB_ISSUER_URL: hub.issuer,
|
|
243
|
+
HUB_CLIENT_ID: host.hubClientId,
|
|
244
|
+
HUB_CLIENT_SECRET: 'integration-test-hub-secret-0001'
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
const unknownApi = await answerOf(
|
|
248
|
+
await send(cloud, 'POST', '/definitely-not-a-route', {}, { email: 'a' })
|
|
249
|
+
);
|
|
250
|
+
const unknownAuth = await answerOf(
|
|
251
|
+
await send(cloud, 'POST', '/auth/definitely-not-a-route', {}, { pass: 'x' })
|
|
252
|
+
);
|
|
253
|
+
expect(await answerOf(await send(cloud, 'POST', '/demo/passes', {}, { email: 'a' }))).toEqual(
|
|
254
|
+
unknownApi
|
|
255
|
+
);
|
|
256
|
+
expect(await answerOf(await send(cloud, 'GET', '/demo/passes'))).toEqual(unknownApi);
|
|
257
|
+
expect(
|
|
258
|
+
await answerOf(await send(cloud, 'DELETE', '/demo/passes/11111111-1111-4111-8111-111111111111'))
|
|
259
|
+
).toEqual(unknownApi);
|
|
260
|
+
expect(await answerOf(await redeem('x', cloud))).toEqual(unknownAuth);
|
|
261
|
+
expect('demoSignIn' in (await readJson(await send(cloud, 'GET', '/instance')))).toBe(false);
|
|
262
|
+
} finally {
|
|
263
|
+
await cloud.stop();
|
|
264
|
+
await hub.stop();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('the mint', () => {
|
|
270
|
+
it('c. an owner mints a pass for a member: 201, a 43-character secret stored only as its sha256, a fragment link', async () => {
|
|
271
|
+
const minted = await mint(actors.owner!, { email: 'member@example.com', path: '/decks/1?x=1' });
|
|
272
|
+
expect(minted.secret).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
273
|
+
expect(minted.url).toBe(`${ORIGIN}/demo#pass=${minted.secret}&to=${encodeURIComponent('/decks/1?x=1')}`);
|
|
274
|
+
expect(minted.pass).toMatchObject({
|
|
275
|
+
userId: actors.member!.userId,
|
|
276
|
+
email: 'member@example.com',
|
|
277
|
+
targetPath: '/decks/1?x=1',
|
|
278
|
+
createdBy: actors.owner!.userId,
|
|
279
|
+
revokedAt: null,
|
|
280
|
+
useCount: 0
|
|
281
|
+
});
|
|
282
|
+
// Default lifetime: one day.
|
|
283
|
+
const lifetime = Date.parse(String(minted.pass.expiresAt)) - Date.parse(String(minted.pass.createdAt));
|
|
284
|
+
expect(Math.round(lifetime / 60_000)).toBe(1440);
|
|
285
|
+
|
|
286
|
+
const { rows } = await app.db.pool.query(
|
|
287
|
+
`SELECT *, row_to_json(p)::text AS json FROM demo_passes p WHERE id = $1`,
|
|
288
|
+
[minted.id]
|
|
289
|
+
);
|
|
290
|
+
expect(rows[0].secret_hash).toBe(sha256(minted.secret));
|
|
291
|
+
expect(rows[0].json).not.toContain(minted.secret);
|
|
292
|
+
expect(rows[0].target_path).toBe('/decks/1?x=1');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('d. the refusals, in the spec’s order', async () => {
|
|
296
|
+
const owner = actors.owner!;
|
|
297
|
+
// 1. nobody of this workspace holds the address
|
|
298
|
+
await expectError(
|
|
299
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'nobody@example.com' }),
|
|
300
|
+
404,
|
|
301
|
+
'no_such_member'
|
|
302
|
+
);
|
|
303
|
+
// 2. another owner
|
|
304
|
+
await expectError(
|
|
305
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'other-owner@example.com' }),
|
|
306
|
+
403,
|
|
307
|
+
'owner_target'
|
|
308
|
+
);
|
|
309
|
+
// …but the owner for themself is fine.
|
|
310
|
+
await mint(owner, { email: OWNER.email });
|
|
311
|
+
// 3. not a demonstration address (checked before the second factor)
|
|
312
|
+
await expectError(
|
|
313
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'someone@gmail.com' }),
|
|
314
|
+
403,
|
|
315
|
+
'demo_address_required'
|
|
316
|
+
);
|
|
317
|
+
await expectError(
|
|
318
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'two-factor@gmail.com' }),
|
|
319
|
+
403,
|
|
320
|
+
'demo_address_required'
|
|
321
|
+
);
|
|
322
|
+
// 4. a second factor
|
|
323
|
+
await expectError(
|
|
324
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'two-factor@example.com' }),
|
|
325
|
+
403,
|
|
326
|
+
'two_factor_enrolled'
|
|
327
|
+
);
|
|
328
|
+
// 5. the shared credential-minting refusal
|
|
329
|
+
await expectError(
|
|
330
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'guest@example.org' }),
|
|
331
|
+
403,
|
|
332
|
+
'guest_target'
|
|
333
|
+
);
|
|
334
|
+
await expectError(
|
|
335
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'cross@team.test' }),
|
|
336
|
+
403,
|
|
337
|
+
'cross_workspace_target'
|
|
338
|
+
);
|
|
339
|
+
// 6. the page and the lifetime
|
|
340
|
+
for (const path of ['//evil', '/\\evil', 'decks', '/a#b', '/a b']) {
|
|
341
|
+
await expectError(
|
|
342
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'member@example.com', path }),
|
|
343
|
+
400,
|
|
344
|
+
'invalid_demo_path'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
for (const expiresInMinutes of [0, 10081]) {
|
|
348
|
+
await expectError(
|
|
349
|
+
await send(app, 'POST', '/demo/passes', owner, { email: 'member@example.com', expiresInMinutes }),
|
|
350
|
+
400,
|
|
351
|
+
'validation_error'
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
await mint(owner, { email: 'member@example.com', expiresInMinutes: 10080 });
|
|
355
|
+
|
|
356
|
+
// The operator names gmail.com: the same member is now a demonstration address.
|
|
357
|
+
const widened = await createTestApp(connectionString, {
|
|
358
|
+
...DEMO_ON,
|
|
359
|
+
DEMO_SIGN_IN_EMAIL_DOMAINS: 'gmail.com'
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
await mint(owner, { email: 'someone@gmail.com' }, widened);
|
|
363
|
+
} finally {
|
|
364
|
+
await widened.stop();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('e. an admin and a member are refused; an API key held by the owner reaches none of the three routes', async () => {
|
|
369
|
+
for (const who of [actors.admin!, actors.member!]) {
|
|
370
|
+
await expectError(
|
|
371
|
+
await send(app, 'POST', '/demo/passes', who, { email: 'member@example.com' }),
|
|
372
|
+
403,
|
|
373
|
+
'forbidden'
|
|
374
|
+
);
|
|
375
|
+
await expectError(await send(app, 'GET', '/demo/passes', who), 403, 'forbidden');
|
|
376
|
+
}
|
|
377
|
+
const keyRes = await send(app, 'POST', '/api-keys', actors.owner!, {
|
|
378
|
+
name: 'demo-key',
|
|
379
|
+
scopes: [host.scopes.read, host.scopes.write]
|
|
380
|
+
});
|
|
381
|
+
expect(keyRes.status).toBe(201);
|
|
382
|
+
const key = (await readJson(keyRes)).key as string;
|
|
383
|
+
const byKey = [
|
|
384
|
+
await send(app, 'POST', '/demo/passes', { key }, { email: 'member@example.com' }),
|
|
385
|
+
await send(app, 'GET', '/demo/passes', { key }),
|
|
386
|
+
await send(app, 'DELETE', '/demo/passes/11111111-1111-4111-8111-111111111111', { key })
|
|
387
|
+
];
|
|
388
|
+
for (const res of byKey) expect(res.status).toBe(403);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('the redeem', () => {
|
|
393
|
+
it('f. signs the MEMBER in with the library’s own session cookie; two redeems, two sessions, use_count 2', async () => {
|
|
394
|
+
const minted = await mint(actors.owner!, { email: 'member@example.com', path: '/decks' });
|
|
395
|
+
const cookies: string[] = [];
|
|
396
|
+
for (let i = 0; i < 2; i++) {
|
|
397
|
+
const res = await redeem(minted.secret);
|
|
398
|
+
expect(res.status).toBe(200);
|
|
399
|
+
expect(res.headers.get('set-cookie')).toMatch(/better-auth\.session_token=/);
|
|
400
|
+
const body = await readJson(res);
|
|
401
|
+
expect(body).toEqual({
|
|
402
|
+
user: { id: actors.member!.userId, email: 'member@example.com', name: 'member' },
|
|
403
|
+
path: '/decks',
|
|
404
|
+
expiresAt: minted.pass.expiresAt
|
|
405
|
+
});
|
|
406
|
+
cookies.push(extractCookie(res));
|
|
407
|
+
}
|
|
408
|
+
expect(cookies[0]).not.toBe(cookies[1]);
|
|
409
|
+
for (const cookie of cookies) {
|
|
410
|
+
const me = await readJson(await send(app, 'GET', '/me', { cookie }));
|
|
411
|
+
expect(me.user.id).toBe(actors.member!.userId);
|
|
412
|
+
expect(me.role).toBe('member');
|
|
413
|
+
// An owner-only route stays closed to the member's session: the demo
|
|
414
|
+
// session list (`DEMO_SESSION_REFUSED_API_ROUTES`) answers before the role.
|
|
415
|
+
await expectError(await send(app, 'GET', '/demo/passes', { cookie }), 403, 'demo_session');
|
|
416
|
+
}
|
|
417
|
+
const { rows } = await app.db.pool.query(
|
|
418
|
+
`SELECT use_count, last_used_at FROM demo_passes WHERE id = $1`,
|
|
419
|
+
[minted.id]
|
|
420
|
+
);
|
|
421
|
+
expect(rows[0].use_count).toBe(2);
|
|
422
|
+
expect(rows[0].last_used_at).not.toBeNull();
|
|
423
|
+
const sessions = await app.db.pool.query(
|
|
424
|
+
`SELECT count(*)::int AS n FROM demo_pass_sessions WHERE pass_id = $1`,
|
|
425
|
+
[minted.id]
|
|
426
|
+
);
|
|
427
|
+
expect(sessions.rows[0].n).toBe(2);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('g. every refusal of a pass is the same 401 and sets no session', async () => {
|
|
431
|
+
const owner = actors.owner!;
|
|
432
|
+
const refusals: Array<{ status: number; body: string; sets: boolean }> = [];
|
|
433
|
+
const collect = async (res: Response) =>
|
|
434
|
+
refusals.push({ ...(await answerOf(res)), sets: setsSession(res) });
|
|
435
|
+
|
|
436
|
+
// unknown
|
|
437
|
+
await collect(await redeem('A'.repeat(43)));
|
|
438
|
+
// expired
|
|
439
|
+
const expired = await mint(owner, { email: 'member@example.com' });
|
|
440
|
+
await app.db.pool.query(`UPDATE demo_passes SET expires_at = now() - interval '1 minute' WHERE id = $1`, [
|
|
441
|
+
expired.id
|
|
442
|
+
]);
|
|
443
|
+
await collect(await redeem(expired.secret));
|
|
444
|
+
// revoked
|
|
445
|
+
const revoked = await mint(owner, { email: 'member@example.com' });
|
|
446
|
+
expect((await send(app, 'DELETE', `/demo/passes/${revoked.id}`, owner)).status).toBe(200);
|
|
447
|
+
await collect(await redeem(revoked.secret));
|
|
448
|
+
// the member removed from the workspace
|
|
449
|
+
const gone = await addMember('gone', 'gone@example.com', { signIn: false });
|
|
450
|
+
const goneMint = await mint(owner, { email: gone.email });
|
|
451
|
+
await app.db.pool.query(`DELETE FROM workspace_members WHERE user_id = $1`, [gone.userId]);
|
|
452
|
+
await collect(await redeem(goneMint.secret));
|
|
453
|
+
// a second factor enrolled after the mint
|
|
454
|
+
const late = await addMember('late', 'late-two-factor@example.com', { signIn: false });
|
|
455
|
+
const lateMint = await mint(owner, { email: late.email });
|
|
456
|
+
await app.db.pool.query(`UPDATE "user" SET two_factor_enabled = true WHERE id = $1`, [late.userId]);
|
|
457
|
+
await collect(await redeem(lateMint.secret));
|
|
458
|
+
// the domain taken off the operator's list: minted where gmail.com was
|
|
459
|
+
// listed, redeemed where it no longer is (the same database)
|
|
460
|
+
const widened = await createTestApp(connectionString, {
|
|
461
|
+
...DEMO_ON,
|
|
462
|
+
DEMO_SIGN_IN_EMAIL_DOMAINS: 'gmail.com'
|
|
463
|
+
});
|
|
464
|
+
let gmailSecret = '';
|
|
465
|
+
try {
|
|
466
|
+
gmailSecret = (await mint(owner, { email: 'someone@gmail.com' }, widened)).secret;
|
|
467
|
+
} finally {
|
|
468
|
+
await widened.stop();
|
|
469
|
+
}
|
|
470
|
+
await collect(await redeem(gmailSecret));
|
|
471
|
+
|
|
472
|
+
expect(refusals).toHaveLength(6);
|
|
473
|
+
for (const refusal of refusals) {
|
|
474
|
+
expect(refusal).toEqual({
|
|
475
|
+
status: 401,
|
|
476
|
+
body: JSON.stringify({
|
|
477
|
+
error: { code: 'invalid_demo_pass', message: 'This demo link is not valid' }
|
|
478
|
+
}),
|
|
479
|
+
sets: false
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
/** The one answer every refused redeem gives (test g), with no session set. */
|
|
485
|
+
async function expectRefusedRedeem(secret: string): Promise<void> {
|
|
486
|
+
const res = await redeem(secret);
|
|
487
|
+
expect({ ...(await answerOf(res)), sets: setsSession(res) }).toEqual({
|
|
488
|
+
status: 401,
|
|
489
|
+
body: JSON.stringify({ error: { code: 'invalid_demo_pass', message: 'This demo link is not valid' } }),
|
|
490
|
+
sets: false
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
it('g3. a member made an owner after the mint is refused at the redeem', async () => {
|
|
495
|
+
const promoted = await addMember('promoted', 'promoted@example.com', { signIn: false });
|
|
496
|
+
const minted = await mint(actors.owner!, { email: promoted.email });
|
|
497
|
+
await app.db.pool.query(`UPDATE workspace_members SET role = 'owner' WHERE user_id = $1`, [
|
|
498
|
+
promoted.userId
|
|
499
|
+
]);
|
|
500
|
+
await expectRefusedRedeem(minted.secret);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('g4. a member who joined another workspace after the mint is refused at the redeem', async () => {
|
|
504
|
+
const joined = await addMember('joined', 'joined@example.com', { signIn: false });
|
|
505
|
+
const minted = await mint(actors.owner!, { email: joined.email });
|
|
506
|
+
// `cross`'s other workspace, the one created in beforeAll.
|
|
507
|
+
const { rows } = await app.db.pool.query(
|
|
508
|
+
`SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id <> $2`,
|
|
509
|
+
[actors.cross!.userId, workspaceId]
|
|
510
|
+
);
|
|
511
|
+
expect(rows).toHaveLength(1);
|
|
512
|
+
await app.db.db
|
|
513
|
+
.insert(workspaceMembers)
|
|
514
|
+
.values({ workspaceId: rows[0].workspace_id, userId: joined.userId, role: 'member', origin: 'local' });
|
|
515
|
+
await expectRefusedRedeem(minted.secret);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('g5. a member whose membership turned guest after the mint is refused at the redeem', async () => {
|
|
519
|
+
const turned = await addMember('turned', 'turned@example.com', { signIn: false });
|
|
520
|
+
const minted = await mint(actors.owner!, { email: turned.email });
|
|
521
|
+
await app.db.pool.query(`UPDATE workspace_members SET origin = 'guest' WHERE user_id = $1`, [
|
|
522
|
+
turned.userId
|
|
523
|
+
]);
|
|
524
|
+
await expectRefusedRedeem(minted.secret);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('h. no Origin: 403 and no cookie; a foreign Origin: refused and no cookie', async () => {
|
|
528
|
+
const minted = await mint(actors.owner!, { email: 'member@example.com' });
|
|
529
|
+
const noOrigin = await app.app.request('/api/v1/auth/demo/redeem', {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
headers: { 'x-forwarded-for': nextIp(), 'content-type': 'application/json' },
|
|
532
|
+
body: JSON.stringify({ pass: minted.secret })
|
|
533
|
+
});
|
|
534
|
+
await expectError(noOrigin, 403, 'cross_site_forbidden');
|
|
535
|
+
expect(setsSession(noOrigin)).toBe(false);
|
|
536
|
+
|
|
537
|
+
const foreign = await redeem(minted.secret, app, { origin: 'https://evil.test' });
|
|
538
|
+
expect(foreign.status).toBe(403);
|
|
539
|
+
expect(setsSession(foreign)).toBe(false);
|
|
540
|
+
|
|
541
|
+
// Neither spent the pass.
|
|
542
|
+
const { rows } = await app.db.pool.query(`SELECT use_count FROM demo_passes WHERE id = $1`, [minted.id]);
|
|
543
|
+
expect(rows[0].use_count).toBe(0);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('i2. valid redeems never cost: thirty in a row from one address all sign in', async () => {
|
|
547
|
+
const minted = await mint(actors.owner!, { email: 'member@example.com' });
|
|
548
|
+
const address = { 'x-forwarded-for': '10.99.1.1' };
|
|
549
|
+
for (let i = 0; i < 30; i++) {
|
|
550
|
+
expect((await redeem(minted.secret, app, address)).status).toBe(200);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('i. the wall: after twenty refused redeems from one address the next answers 429, a valid pass included', async () => {
|
|
555
|
+
const minted = await mint(actors.owner!, { email: 'member@example.com' });
|
|
556
|
+
const address = { 'x-forwarded-for': '10.99.0.1' };
|
|
557
|
+
for (let i = 0; i < 20; i++) {
|
|
558
|
+
expect((await redeem('B'.repeat(43), app, address)).status).toBe(401);
|
|
559
|
+
}
|
|
560
|
+
await expectError(await redeem('B'.repeat(43), app, address), 429, 'rate_limited');
|
|
561
|
+
// A drained wall refuses a valid pass too: no work is done for that address.
|
|
562
|
+
await expectError(await redeem(minted.secret, app, address), 429, 'rate_limited');
|
|
563
|
+
// Another address is untouched.
|
|
564
|
+
expect((await redeem('B'.repeat(43), app, { 'x-forwarded-for': '10.99.0.2' })).status).toBe(401);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('the trail and the owner’s list', () => {
|
|
569
|
+
it('j. mint, use and revoke rows with the right actor, no secret or hash anywhere, and a pass session’s mutation marked', async () => {
|
|
570
|
+
const owner = actors.owner!;
|
|
571
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
572
|
+
const redeemed = await redeem(minted.secret);
|
|
573
|
+
expect(redeemed.status).toBe(200);
|
|
574
|
+
const passCookie = extractCookie(redeemed);
|
|
575
|
+
|
|
576
|
+
const rowsOf = async (action: string) =>
|
|
577
|
+
(
|
|
578
|
+
await app.db.pool.query(
|
|
579
|
+
`SELECT actor_user_id, actor_via, workspace_id, resource_type, metadata FROM audit_log
|
|
580
|
+
WHERE action = $1 AND resource_id = $2`,
|
|
581
|
+
[action, minted.id]
|
|
582
|
+
)
|
|
583
|
+
).rows;
|
|
584
|
+
const [mintRow] = await rowsOf('demo_pass.mint');
|
|
585
|
+
expect(mintRow).toMatchObject({
|
|
586
|
+
actor_user_id: owner.userId,
|
|
587
|
+
actor_via: 'session',
|
|
588
|
+
workspace_id: workspaceId,
|
|
589
|
+
resource_type: 'demo_pass',
|
|
590
|
+
metadata: { targetUserId: actors.member!.userId, targetPath: '/', expiresAt: minted.pass.expiresAt }
|
|
591
|
+
});
|
|
592
|
+
const [useRow] = await rowsOf('demo_pass.use');
|
|
593
|
+
expect(useRow).toMatchObject({
|
|
594
|
+
actor_user_id: actors.member!.userId,
|
|
595
|
+
actor_via: 'session',
|
|
596
|
+
workspace_id: workspaceId,
|
|
597
|
+
metadata: { passId: minted.id }
|
|
598
|
+
});
|
|
599
|
+
// A mutation a member may make, once through the pass's session, once
|
|
600
|
+
// through the member's own password session.
|
|
601
|
+
const viaPass = await send(
|
|
602
|
+
app,
|
|
603
|
+
'POST',
|
|
604
|
+
'/projects',
|
|
605
|
+
{ cookie: passCookie },
|
|
606
|
+
{ name: 'Through the demo link' }
|
|
607
|
+
);
|
|
608
|
+
expect(viaPass.status).toBe(201);
|
|
609
|
+
const viaPassId = (await readJson(viaPass)).id;
|
|
610
|
+
const viaPassword = await send(app, 'POST', '/projects', actors.member!, { name: 'By hand' });
|
|
611
|
+
expect(viaPassword.status).toBe(201);
|
|
612
|
+
const viaPasswordId = (await readJson(viaPassword)).id;
|
|
613
|
+
const metadataOf = async (projectId: string) =>
|
|
614
|
+
(
|
|
615
|
+
await app.db.pool.query(
|
|
616
|
+
`SELECT metadata, actor_user_id FROM audit_log WHERE action = 'project.create' AND resource_id = $1`,
|
|
617
|
+
[projectId]
|
|
618
|
+
)
|
|
619
|
+
).rows[0];
|
|
620
|
+
const marked = await metadataOf(viaPassId);
|
|
621
|
+
expect(marked.actor_user_id).toBe(actors.member!.userId);
|
|
622
|
+
expect(marked.metadata.demoPassId).toBe(minted.id);
|
|
623
|
+
const unmarked = await metadataOf(viaPasswordId);
|
|
624
|
+
expect(unmarked.actor_user_id).toBe(actors.member!.userId);
|
|
625
|
+
expect(unmarked.metadata?.demoPassId).toBeUndefined();
|
|
626
|
+
|
|
627
|
+
expect((await send(app, 'DELETE', `/demo/passes/${minted.id}`, owner)).status).toBe(200);
|
|
628
|
+
const [revokeRow] = await rowsOf('demo_pass.revoke');
|
|
629
|
+
expect(revokeRow).toMatchObject({ actor_user_id: owner.userId, resource_type: 'demo_pass' });
|
|
630
|
+
|
|
631
|
+
// No audit row anywhere carries a secret this suite minted, or its hash.
|
|
632
|
+
const { rows: all } = await app.db.pool.query(`SELECT row_to_json(a)::text AS json FROM audit_log a`);
|
|
633
|
+
const trail = all.map((r: { json: string }) => r.json).join('\n');
|
|
634
|
+
expect(secrets.length).toBeGreaterThan(5);
|
|
635
|
+
for (const secret of secrets) {
|
|
636
|
+
expect(trail).not.toContain(secret);
|
|
637
|
+
expect(trail).not.toContain(sha256(secret));
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('k. revoke is idempotent, another workspace’s pass is 404, and the list never carries a secret', async () => {
|
|
642
|
+
const owner = actors.owner!;
|
|
643
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
644
|
+
const first = await readJson(await send(app, 'DELETE', `/demo/passes/${minted.id}`, owner));
|
|
645
|
+
expect(first.revokedAt).not.toBeNull();
|
|
646
|
+
const again = await send(app, 'DELETE', `/demo/passes/${minted.id}`, owner);
|
|
647
|
+
expect(again.status).toBe(200);
|
|
648
|
+
expect((await readJson(again)).revokedAt).toBe(first.revokedAt);
|
|
649
|
+
|
|
650
|
+
// A pass of `cross`'s other workspace, written in place.
|
|
651
|
+
const { rows: elsewhere } = await app.db.pool.query(
|
|
652
|
+
`SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id <> $2`,
|
|
653
|
+
[actors.cross!.userId, workspaceId]
|
|
654
|
+
);
|
|
655
|
+
const { rows: foreign } = await app.db.pool.query(
|
|
656
|
+
`INSERT INTO demo_passes (workspace_id, user_id, secret_hash, expires_at)
|
|
657
|
+
VALUES ($1, $2, $3, now() + interval '1 day') RETURNING id`,
|
|
658
|
+
[elsewhere[0].workspace_id, actors.cross!.userId, sha256('elsewhere')]
|
|
659
|
+
);
|
|
660
|
+
await expectError(await send(app, 'DELETE', `/demo/passes/${foreign[0].id}`, owner), 404, 'not_found');
|
|
661
|
+
await expectError(await send(app, 'DELETE', '/demo/passes/not-a-uuid', owner), 400, 'validation_error');
|
|
662
|
+
|
|
663
|
+
const listRes = await send(app, 'GET', '/demo/passes', owner);
|
|
664
|
+
expect(listRes.status).toBe(200);
|
|
665
|
+
const text = await listRes.text();
|
|
666
|
+
const list = JSON.parse(text) as { passes: Array<{ id: string; createdAt: string }> };
|
|
667
|
+
expect(list.passes.length).toBeGreaterThan(5);
|
|
668
|
+
expect(list.passes.map((p) => p.id)).not.toContain(foreign[0].id);
|
|
669
|
+
const created = list.passes.map((p) => Date.parse(p.createdAt));
|
|
670
|
+
expect([...created].sort((a, b) => b - a)).toEqual(created);
|
|
671
|
+
expect(text).not.toMatch(/secret/i);
|
|
672
|
+
for (const secret of secrets) {
|
|
673
|
+
expect(text).not.toContain(secret);
|
|
674
|
+
expect(text).not.toContain(sha256(secret));
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
describe('a session a pass opened lives only while its pass does', () => {
|
|
680
|
+
const sessionRowsOf = async (passId: string) =>
|
|
681
|
+
(
|
|
682
|
+
await app.db.pool.query(
|
|
683
|
+
`SELECT s.id FROM session s JOIN demo_pass_sessions d ON d.session_id = s.id WHERE d.pass_id = $1`,
|
|
684
|
+
[passId]
|
|
685
|
+
)
|
|
686
|
+
).rows.length;
|
|
687
|
+
/** The pass's session links, whether or not their session is still there. */
|
|
688
|
+
const linksOf = async (passId: string) =>
|
|
689
|
+
(await app.db.pool.query(`SELECT session_id FROM demo_pass_sessions WHERE pass_id = $1`, [passId])).rows
|
|
690
|
+
.length;
|
|
691
|
+
|
|
692
|
+
it('m. a revoke ends the sessions the pass opened, and leaves the person’s own session alone', async () => {
|
|
693
|
+
const owner = actors.owner!;
|
|
694
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
695
|
+
const first = extractCookie(await redeem(minted.secret));
|
|
696
|
+
const second = extractCookie(await redeem(minted.secret));
|
|
697
|
+
expect((await send(app, 'GET', '/me', { cookie: first })).status).toBe(200);
|
|
698
|
+
expect(await sessionRowsOf(minted.id)).toBe(2);
|
|
699
|
+
|
|
700
|
+
expect((await send(app, 'DELETE', `/demo/passes/${minted.id}`, owner)).status).toBe(200);
|
|
701
|
+
|
|
702
|
+
expect(await sessionRowsOf(minted.id)).toBe(0);
|
|
703
|
+
expect(await linksOf(minted.id)).toBe(0);
|
|
704
|
+
expect((await send(app, 'GET', '/me', { cookie: first })).status).toBe(401);
|
|
705
|
+
expect((await send(app, 'GET', '/me', { cookie: second })).status).toBe(401);
|
|
706
|
+
const session = await app.app.request('/api/v1/auth/get-session', { headers: { cookie: first } });
|
|
707
|
+
expect(await session.json()).toBeNull();
|
|
708
|
+
// The member's password session is theirs: the revoke does not touch it.
|
|
709
|
+
expect((await send(app, 'GET', '/me', actors.member!)).status).toBe(200);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('n. once the pass has expired its session is refused and deleted, on the first request that presents it', async () => {
|
|
713
|
+
const owner = actors.owner!;
|
|
714
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
715
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
716
|
+
expect((await send(app, 'GET', '/me', { cookie })).status).toBe(200);
|
|
717
|
+
expect(await sessionRowsOf(minted.id)).toBe(1);
|
|
718
|
+
|
|
719
|
+
await app.db.pool.query(`UPDATE demo_passes SET expires_at = now() - interval '1 minute' WHERE id = $1`, [
|
|
720
|
+
minted.id
|
|
721
|
+
]);
|
|
722
|
+
|
|
723
|
+
expect((await send(app, 'GET', '/me', { cookie })).status).toBe(401);
|
|
724
|
+
expect(await sessionRowsOf(minted.id)).toBe(0);
|
|
725
|
+
expect(await linksOf(minted.id)).toBe(0);
|
|
726
|
+
const made = await send(app, 'POST', '/projects', { cookie }, { name: 'After the pass died' });
|
|
727
|
+
expect(made.status).toBe(401);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('o. an ordinary session is never judged against a pass: the member’s own sign-in works whatever their passes do', async () => {
|
|
731
|
+
const owner = actors.owner!;
|
|
732
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
733
|
+
await redeem(minted.secret);
|
|
734
|
+
await app.db.pool.query(`UPDATE demo_passes SET expires_at = now() - interval '1 minute' WHERE id = $1`, [
|
|
735
|
+
minted.id
|
|
736
|
+
]);
|
|
737
|
+
expect((await send(app, 'GET', '/me', actors.member!)).status).toBe(200);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('p. the sign-in library’s own door: get-session on an expired pass’s session answers null and deletes the row, with no API request in between', async () => {
|
|
741
|
+
const owner = actors.owner!;
|
|
742
|
+
const minted = await mint(owner, { email: 'member@example.com' });
|
|
743
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
744
|
+
const live = await app.app.request('/api/v1/auth/get-session', { headers: { cookie } });
|
|
745
|
+
expect(((await live.json()) as { user: { email: string } }).user.email).toBe('member@example.com');
|
|
746
|
+
|
|
747
|
+
await app.db.pool.query(`UPDATE demo_passes SET expires_at = now() - interval '1 minute' WHERE id = $1`, [
|
|
748
|
+
minted.id
|
|
749
|
+
]);
|
|
750
|
+
|
|
751
|
+
const dead = await app.app.request('/api/v1/auth/get-session', { headers: { cookie } });
|
|
752
|
+
expect(await dead.json()).toBeNull();
|
|
753
|
+
expect(await sessionRowsOf(minted.id)).toBe(0);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('q. a session a demo link opened manages no demo link, even the owner’s own', async () => {
|
|
757
|
+
const owner = actors.owner!;
|
|
758
|
+
const minted = await mint(owner, { email: OWNER.email });
|
|
759
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
760
|
+
const me = (await readJson(await send(app, 'GET', '/me', { cookie }))) as { role: string };
|
|
761
|
+
expect(me.role).toBe('owner');
|
|
762
|
+
|
|
763
|
+
await expectError(await send(app, 'GET', '/demo/passes', { cookie }), 403, 'demo_session');
|
|
764
|
+
await expectError(
|
|
765
|
+
await send(app, 'POST', '/demo/passes', { cookie }, { email: 'member@example.com' }),
|
|
766
|
+
403,
|
|
767
|
+
'demo_session'
|
|
768
|
+
);
|
|
769
|
+
await expectError(
|
|
770
|
+
await send(app, 'DELETE', `/demo/passes/${minted.id}`, { cookie }),
|
|
771
|
+
403,
|
|
772
|
+
'demo_session'
|
|
773
|
+
);
|
|
774
|
+
// The owner's password session still does all three.
|
|
775
|
+
expect((await send(app, 'GET', '/demo/passes', owner)).status).toBe(200);
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
describe('a demo link’s session leaves nothing behind', () => {
|
|
780
|
+
/** A fresh member and a session a pass opened for them. */
|
|
781
|
+
async function passSessionFor(name: string, email: string): Promise<{ actor: Actor; cookie: string }> {
|
|
782
|
+
const actor = await addMember(name, email);
|
|
783
|
+
const minted = await mint(actors.owner!, { email });
|
|
784
|
+
const res = await redeem(minted.secret);
|
|
785
|
+
expect(res.status).toBe(200);
|
|
786
|
+
return { actor, cookie: extractCookie(res) };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
it('t. a pass’s session creates no API key; the person’s own password session does', async () => {
|
|
790
|
+
const { actor, cookie } = await passSessionFor('keyless', 'keyless@example.com');
|
|
791
|
+
const body = { name: 'from-a-demo', scopes: [host.scopes.read] };
|
|
792
|
+
await expectError(await send(app, 'POST', '/api-keys', { cookie }, body), 403, 'demo_session');
|
|
793
|
+
const keysOf = async () =>
|
|
794
|
+
(await app.db.pool.query(`SELECT id FROM api_keys WHERE created_by = $1`, [actor.userId])).rows.length;
|
|
795
|
+
expect(await keysOf()).toBe(0);
|
|
796
|
+
|
|
797
|
+
expect((await send(app, 'POST', '/api-keys', actor, body)).status).toBe(201);
|
|
798
|
+
expect(await keysOf()).toBe(1);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('t2. a pass’s session changes no password, address, name or second factor, and deletes no account', async () => {
|
|
802
|
+
const { actor, cookie } = await passSessionFor('unchanged', 'unchanged@example.com');
|
|
803
|
+
const snapshot = async () => ({
|
|
804
|
+
user: (
|
|
805
|
+
await app.db.pool.query(`SELECT name, email, two_factor_enabled FROM "user" WHERE id = $1`, [
|
|
806
|
+
actor.userId
|
|
807
|
+
])
|
|
808
|
+
).rows,
|
|
809
|
+
password: (
|
|
810
|
+
await app.db.pool.query(`SELECT password FROM account WHERE user_id = $1 ORDER BY id`, [actor.userId])
|
|
811
|
+
).rows
|
|
812
|
+
});
|
|
813
|
+
const before = await snapshot();
|
|
814
|
+
expect(before.user).toHaveLength(1);
|
|
815
|
+
|
|
816
|
+
const attempts: Array<[string, unknown]> = [
|
|
817
|
+
['/auth/change-password', { currentPassword: PASSWORD, newPassword: 'another-long-password-2' }],
|
|
818
|
+
['/auth/change-email', { newEmail: 'moved@example.com' }],
|
|
819
|
+
['/auth/update-user', { name: 'Renamed by a demo' }],
|
|
820
|
+
['/auth/two-factor/enable', { password: PASSWORD }],
|
|
821
|
+
['/auth/delete-user', {}]
|
|
822
|
+
];
|
|
823
|
+
for (const [path, body] of attempts) {
|
|
824
|
+
await expectError(await send(app, 'POST', path, { cookie }, body), 403, 'demo_session');
|
|
825
|
+
}
|
|
826
|
+
expect(await snapshot()).toEqual(before);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('t3. a pass’s session authorizes no OAuth client', async () => {
|
|
830
|
+
const { cookie } = await passSessionFor('noGrant', 'no-grant@example.com');
|
|
831
|
+
const redirectUri = 'http://127.0.0.1:9999/callback';
|
|
832
|
+
const register = await send(
|
|
833
|
+
app,
|
|
834
|
+
'POST',
|
|
835
|
+
'/auth/oauth2/register',
|
|
836
|
+
{},
|
|
837
|
+
{
|
|
838
|
+
client_name: 'demo-pass-client',
|
|
839
|
+
redirect_uris: [redirectUri],
|
|
840
|
+
token_endpoint_auth_method: 'none',
|
|
841
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
842
|
+
response_types: ['code']
|
|
843
|
+
}
|
|
844
|
+
);
|
|
845
|
+
expect([200, 201]).toContain(register.status);
|
|
846
|
+
const clientId = (await readJson(register)).client_id as string;
|
|
847
|
+
expect(clientId).toBeTruthy();
|
|
848
|
+
|
|
849
|
+
const verifier = 'v'.repeat(64);
|
|
850
|
+
const params = new URLSearchParams({
|
|
851
|
+
response_type: 'code',
|
|
852
|
+
client_id: clientId,
|
|
853
|
+
redirect_uri: redirectUri,
|
|
854
|
+
scope: `openid offline_access ${host.scopes.read}`,
|
|
855
|
+
state: 'demo-state',
|
|
856
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
857
|
+
code_challenge_method: 'S256'
|
|
858
|
+
});
|
|
859
|
+
await expectError(
|
|
860
|
+
await send(app, 'GET', `/auth/oauth2/authorize?${params}`, { cookie }),
|
|
861
|
+
403,
|
|
862
|
+
'demo_session'
|
|
863
|
+
);
|
|
864
|
+
const { rows } = await app.db.pool.query(
|
|
865
|
+
`SELECT count(*)::int AS n FROM oauth_consent WHERE client_id = $1`,
|
|
866
|
+
[clientId]
|
|
867
|
+
);
|
|
868
|
+
expect(rows[0].n).toBe(0);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('v. a pass’s session creates no workspace and is not offered one; the person’s own password session is', async () => {
|
|
872
|
+
const { actor, cookie } = await passSessionFor('founder', 'founder@example.com');
|
|
873
|
+
const ownedBy = async () =>
|
|
874
|
+
(
|
|
875
|
+
await app.db.pool.query(
|
|
876
|
+
`SELECT id FROM workspace_members WHERE user_id = $1 AND role = 'owner' AND is_active`,
|
|
877
|
+
[actor.userId]
|
|
878
|
+
)
|
|
879
|
+
).rows.length;
|
|
880
|
+
expect(await ownedBy()).toBe(0);
|
|
881
|
+
|
|
882
|
+
const me = (await readJson(await send(app, 'GET', '/me', { cookie }))) as { canCreateWorkspace: boolean };
|
|
883
|
+
expect(me.canCreateWorkspace).toBe(false);
|
|
884
|
+
await expectError(
|
|
885
|
+
await send(app, 'POST', '/workspaces', { cookie }, { name: 'Made by a demo' }),
|
|
886
|
+
403,
|
|
887
|
+
'demo_session'
|
|
888
|
+
);
|
|
889
|
+
expect(await ownedBy()).toBe(0);
|
|
890
|
+
|
|
891
|
+
const own = (await readJson(await send(app, 'GET', '/me', actor))) as { canCreateWorkspace: boolean };
|
|
892
|
+
expect(own.canCreateWorkspace).toBe(true);
|
|
893
|
+
expect((await send(app, 'POST', '/workspaces', actor, { name: 'Made by the person' })).status).toBe(201);
|
|
894
|
+
expect(await ownedBy()).toBe(1);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('v2. a pass’s session accepts no invitation into another workspace; the person’s own password session does', async () => {
|
|
898
|
+
const { actor, cookie } = await passSessionFor('invitee', 'invitee@example.com');
|
|
899
|
+
// `cross`'s other workspace, the one created in beforeAll.
|
|
900
|
+
const { rows } = await app.db.pool.query(
|
|
901
|
+
`SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id <> $2`,
|
|
902
|
+
[actors.cross!.userId, workspaceId]
|
|
903
|
+
);
|
|
904
|
+
expect(rows).toHaveLength(1);
|
|
905
|
+
const elsewhere = rows[0].workspace_id as string;
|
|
906
|
+
const invite = await send(
|
|
907
|
+
app,
|
|
908
|
+
'POST',
|
|
909
|
+
'/invitations',
|
|
910
|
+
actors.cross!,
|
|
911
|
+
{ email: actor.email, role: 'member' },
|
|
912
|
+
{ 'x-workspace-id': elsewhere }
|
|
913
|
+
);
|
|
914
|
+
expect(invite.status).toBe(201);
|
|
915
|
+
const token = ((await readJson(invite)).acceptUrl as string).split('/invite/')[1]!;
|
|
916
|
+
const membershipsElsewhere = async () =>
|
|
917
|
+
(
|
|
918
|
+
await app.db.pool.query(`SELECT id FROM workspace_members WHERE user_id = $1 AND workspace_id = $2`, [
|
|
919
|
+
actor.userId,
|
|
920
|
+
elsewhere
|
|
921
|
+
])
|
|
922
|
+
).rows.length;
|
|
923
|
+
|
|
924
|
+
await expectError(
|
|
925
|
+
await send(app, 'POST', '/invitations/accept', { cookie }, { token }),
|
|
926
|
+
403,
|
|
927
|
+
'demo_session'
|
|
928
|
+
);
|
|
929
|
+
expect(await membershipsElsewhere()).toBe(0);
|
|
930
|
+
|
|
931
|
+
expect((await send(app, 'POST', '/invitations/accept', actor, { token })).status).toBe(200);
|
|
932
|
+
expect(await membershipsElsewhere()).toBe(1);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('v3. a pass’s session changes no default workspace; the person’s own password session does (PRDCT-2815)', async () => {
|
|
936
|
+
const { actor, cookie } = await passSessionFor('settled', 'settled@example.com');
|
|
937
|
+
const defaults = async () =>
|
|
938
|
+
(
|
|
939
|
+
await app.db.pool.query(
|
|
940
|
+
`SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND is_default`,
|
|
941
|
+
[actor.userId]
|
|
942
|
+
)
|
|
943
|
+
).rows.map((row) => row.workspace_id as string);
|
|
944
|
+
expect(await defaults()).toEqual([]);
|
|
945
|
+
|
|
946
|
+
for (const body of [{ workspaceId }, { workspaceId: null }]) {
|
|
947
|
+
await expectError(
|
|
948
|
+
await send(app, 'PUT', '/me/default-workspace', { cookie }, body),
|
|
949
|
+
403,
|
|
950
|
+
'demo_session'
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
expect(await defaults()).toEqual([]);
|
|
954
|
+
|
|
955
|
+
expect((await send(app, 'PUT', '/me/default-workspace', actor, { workspaceId })).status).toBe(200);
|
|
956
|
+
expect(await defaults()).toEqual([workspaceId]);
|
|
957
|
+
// The pass's session refuses the clear too, once a default exists.
|
|
958
|
+
await expectError(
|
|
959
|
+
await send(app, 'PUT', '/me/default-workspace', { cookie }, { workspaceId: null }),
|
|
960
|
+
403,
|
|
961
|
+
'demo_session'
|
|
962
|
+
);
|
|
963
|
+
expect(await defaults()).toEqual([workspaceId]);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('t4. a pass’s session still reads itself and signs out', async () => {
|
|
967
|
+
const { cookie } = await passSessionFor('visitor', 'visitor@example.com');
|
|
968
|
+
const session = await send(app, 'GET', '/auth/get-session', { cookie });
|
|
969
|
+
expect(session.status).toBe(200);
|
|
970
|
+
expect(((await readJson(session)) as { user: { email: string } }).user.email).toBe('visitor@example.com');
|
|
971
|
+
|
|
972
|
+
const out = await send(app, 'POST', '/auth/sign-out', { cookie }, {});
|
|
973
|
+
expect(out.status).toBe(200);
|
|
974
|
+
expect((await send(app, 'GET', '/me', { cookie })).status).toBe(401);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
/** The session rows and the link rows a pass opened. */
|
|
978
|
+
const leftOf = async (passId: string) => ({
|
|
979
|
+
links: (await app.db.pool.query(`SELECT session_id FROM demo_pass_sessions WHERE pass_id = $1`, [passId]))
|
|
980
|
+
.rows.length,
|
|
981
|
+
sessions: (
|
|
982
|
+
await app.db.pool.query(
|
|
983
|
+
`SELECT s.id FROM session s JOIN demo_pass_sessions d ON d.session_id = s.id WHERE d.pass_id = $1`,
|
|
984
|
+
[passId]
|
|
985
|
+
)
|
|
986
|
+
).rows.length
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('w. a pass’s session that signs itself out ends the pass’s way: no link row and no session left', async () => {
|
|
990
|
+
await addMember('leaver', 'leaver@example.com', { signIn: false });
|
|
991
|
+
const minted = await mint(actors.owner!, { email: 'leaver@example.com' });
|
|
992
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
993
|
+
expect(await leftOf(minted.id)).toEqual({ links: 1, sessions: 1 });
|
|
994
|
+
|
|
995
|
+
expect((await send(app, 'POST', '/auth/sign-out', { cookie }, {})).status).toBe(200);
|
|
996
|
+
|
|
997
|
+
expect(await leftOf(minted.id)).toEqual({ links: 0, sessions: 0 });
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('w2. the person’s own session revoking a pass’s session ends it the pass’s way: no link row left', async () => {
|
|
1001
|
+
const actor = await addMember('revoker', 'revoker@example.com');
|
|
1002
|
+
const minted = await mint(actors.owner!, { email: actor.email });
|
|
1003
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
1004
|
+
const { rows } = await app.db.pool.query(
|
|
1005
|
+
`SELECT s.token FROM session s JOIN demo_pass_sessions d ON d.session_id = s.id WHERE d.pass_id = $1`,
|
|
1006
|
+
[minted.id]
|
|
1007
|
+
);
|
|
1008
|
+
expect(rows).toHaveLength(1);
|
|
1009
|
+
|
|
1010
|
+
const revoked = await send(app, 'POST', '/auth/revoke-session', actor, { token: rows[0].token });
|
|
1011
|
+
expect(revoked.status).toBe(200);
|
|
1012
|
+
|
|
1013
|
+
expect(await leftOf(minted.id)).toEqual({ links: 0, sessions: 0 });
|
|
1014
|
+
expect((await send(app, 'GET', '/me', { cookie })).status).toBe(401);
|
|
1015
|
+
expect((await send(app, 'GET', '/me', actor)).status).toBe(200);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
/** The session rows (id and token) a pass opened. */
|
|
1019
|
+
const passSessionsOf = async (passId: string) =>
|
|
1020
|
+
(
|
|
1021
|
+
await app.db.pool.query(
|
|
1022
|
+
`SELECT s.id, s.token FROM session s JOIN demo_pass_sessions d ON d.session_id = s.id WHERE d.pass_id = $1`,
|
|
1023
|
+
[passId]
|
|
1024
|
+
)
|
|
1025
|
+
).rows as Array<{ id: string; token: string }>;
|
|
1026
|
+
|
|
1027
|
+
it('w3. a forged signature on the person’s own session cookie ends none of their pass’s sessions', async () => {
|
|
1028
|
+
const actor = await addMember('forger', 'forger@example.com');
|
|
1029
|
+
const minted = await mint(actors.owner!, { email: actor.email });
|
|
1030
|
+
const first = extractCookie(await redeem(minted.secret));
|
|
1031
|
+
const second = extractCookie(await redeem(minted.secret));
|
|
1032
|
+
expect(await leftOf(minted.id)).toEqual({ links: 2, sessions: 2 });
|
|
1033
|
+
// The token part kept, the signature replaced: the library refuses the caller.
|
|
1034
|
+
const forged = actor.cookie.replace(/(session_token=[^.;]+)\.[^;]*/, '$1.forged');
|
|
1035
|
+
expect(forged).not.toBe(actor.cookie);
|
|
1036
|
+
|
|
1037
|
+
const revoked = await send(app, 'POST', '/auth/revoke-sessions', { cookie: forged }, {});
|
|
1038
|
+
expect(revoked.status).toBe(401);
|
|
1039
|
+
|
|
1040
|
+
expect(await leftOf(minted.id)).toEqual({ links: 2, sessions: 2 });
|
|
1041
|
+
expect((await send(app, 'GET', '/me', { cookie: first })).status).toBe(200);
|
|
1042
|
+
expect((await send(app, 'GET', '/me', { cookie: second })).status).toBe(200);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('w4. another person’s session naming a pass’s session token ends nothing of it', async () => {
|
|
1046
|
+
const actor = await addMember('named', 'named@example.com');
|
|
1047
|
+
const other = await addMember('namer', 'namer@example.com');
|
|
1048
|
+
const minted = await mint(actors.owner!, { email: actor.email });
|
|
1049
|
+
const cookie = extractCookie(await redeem(minted.secret));
|
|
1050
|
+
const [passSession] = await passSessionsOf(minted.id);
|
|
1051
|
+
expect(passSession).toBeTruthy();
|
|
1052
|
+
|
|
1053
|
+
const revoked = await send(app, 'POST', '/auth/revoke-session', other, { token: passSession!.token });
|
|
1054
|
+
expect(revoked.status).toBe(200);
|
|
1055
|
+
|
|
1056
|
+
expect(await leftOf(minted.id)).toEqual({ links: 1, sessions: 1 });
|
|
1057
|
+
expect((await send(app, 'GET', '/me', { cookie })).status).toBe(200);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('w5. the person revokes ONE of their pass’s sessions by token: the other and its link remain', async () => {
|
|
1061
|
+
const actor = await addMember('selective', 'selective@example.com');
|
|
1062
|
+
const minted = await mint(actors.owner!, { email: actor.email });
|
|
1063
|
+
const first = extractCookie(await redeem(minted.secret));
|
|
1064
|
+
const second = extractCookie(await redeem(minted.secret));
|
|
1065
|
+
const sessions = await passSessionsOf(minted.id);
|
|
1066
|
+
expect(sessions).toHaveLength(2);
|
|
1067
|
+
// Which row is the first cookie's: its token part.
|
|
1068
|
+
const firstToken = decodeURIComponent(first).match(/session_token=([^.;]+)/)![1]!;
|
|
1069
|
+
const target = sessions.find((row) => row.token === firstToken)!;
|
|
1070
|
+
expect(target).toBeTruthy();
|
|
1071
|
+
|
|
1072
|
+
const revoked = await send(app, 'POST', '/auth/revoke-session', actor, { token: target.token });
|
|
1073
|
+
expect(revoked.status).toBe(200);
|
|
1074
|
+
|
|
1075
|
+
expect(await leftOf(minted.id)).toEqual({ links: 1, sessions: 1 });
|
|
1076
|
+
expect((await passSessionsOf(minted.id)).map((row) => row.id)).toEqual(
|
|
1077
|
+
sessions.filter((row) => row.id !== target.id).map((row) => row.id)
|
|
1078
|
+
);
|
|
1079
|
+
expect((await send(app, 'GET', '/me', { cookie: second })).status).toBe(200);
|
|
1080
|
+
expect((await send(app, 'GET', '/me', { cookie: first })).status).toBe(401);
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
describe('a removal ends the demo passes (PRDCT-2816)', () => {
|
|
1085
|
+
const INVALID = JSON.stringify({
|
|
1086
|
+
error: { code: 'invalid_demo_pass', message: 'This demo link is not valid' }
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const memberIdOf = async (userId: string, inWorkspace = workspaceId) =>
|
|
1090
|
+
(
|
|
1091
|
+
await app.db.pool.query(`SELECT id FROM workspace_members WHERE user_id = $1 AND workspace_id = $2`, [
|
|
1092
|
+
userId,
|
|
1093
|
+
inWorkspace
|
|
1094
|
+
])
|
|
1095
|
+
).rows[0].id as string;
|
|
1096
|
+
const revokedAtOf = async (passId: string) =>
|
|
1097
|
+
(await app.db.pool.query(`SELECT revoked_at FROM demo_passes WHERE id = $1`, [passId])).rows[0]
|
|
1098
|
+
.revoked_at as Date | null;
|
|
1099
|
+
const removeMember = (memberId: string) =>
|
|
1100
|
+
send(app, 'POST', `/members/${memberId}/remove`, actors.owner!, undefined);
|
|
1101
|
+
const removalMetadata = async (memberId: string) => {
|
|
1102
|
+
const { rows } = await app.db.pool.query(
|
|
1103
|
+
`SELECT metadata FROM audit_log WHERE action = 'member.remove' AND resource_id = $1`,
|
|
1104
|
+
[memberId]
|
|
1105
|
+
);
|
|
1106
|
+
expect(rows).toHaveLength(1);
|
|
1107
|
+
return rows[0].metadata as Record<string, unknown>;
|
|
1108
|
+
};
|
|
1109
|
+
async function expectRedeemRefused(secret: string): Promise<void> {
|
|
1110
|
+
const res = await redeem(secret);
|
|
1111
|
+
expect({ status: res.status, body: await res.text(), sets: setsSession(res) }).toEqual({
|
|
1112
|
+
status: 401,
|
|
1113
|
+
body: INVALID,
|
|
1114
|
+
sets: false
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
it('F1: a removed owner’s passes are revoked, their redeem refused, and the sessions they opened signed out', async () => {
|
|
1119
|
+
const o2 = await addMember('o2', 'o2@example.com', { role: 'owner' });
|
|
1120
|
+
const o2MemberId = await memberIdOf(o2.userId);
|
|
1121
|
+
// o2 owns a workspace of their own too, with a demo person in it.
|
|
1122
|
+
const made = await send(app, 'POST', '/workspaces', o2, { name: 'O2 elsewhere' });
|
|
1123
|
+
expect(made.status).toBe(201);
|
|
1124
|
+
const { rows: o2Rows } = await app.db.pool.query(
|
|
1125
|
+
`SELECT workspace_id FROM workspace_members WHERE user_id = $1 AND workspace_id <> $2`,
|
|
1126
|
+
[o2.userId, workspaceId]
|
|
1127
|
+
);
|
|
1128
|
+
expect(o2Rows).toHaveLength(1);
|
|
1129
|
+
const o2Elsewhere = o2Rows[0].workspace_id as string;
|
|
1130
|
+
const far = await app.auth.api.signUpEmail({
|
|
1131
|
+
body: { email: 'far-demo@example.com', password: PASSWORD, name: 'far' }
|
|
1132
|
+
});
|
|
1133
|
+
await app.db.db
|
|
1134
|
+
.insert(workspaceMembers)
|
|
1135
|
+
.values({ workspaceId: o2Elsewhere, userId: far.user.id, role: 'member', origin: 'local' });
|
|
1136
|
+
|
|
1137
|
+
await addMember('f1Target', 'f1-target@example.com', { signIn: false });
|
|
1138
|
+
await addMember('f1Other', 'f1-other@example.com', { signIn: false });
|
|
1139
|
+
await addMember('f1Bystander', 'f1-bystander@example.com', { signIn: false });
|
|
1140
|
+
|
|
1141
|
+
// Two passes o2 minted here, one of them redeemed before the removal.
|
|
1142
|
+
const opened = await mint(o2, { email: 'f1-target@example.com' });
|
|
1143
|
+
const idle = await mint(o2, { email: 'f1-other@example.com' });
|
|
1144
|
+
const redeemed = await redeem(opened.secret);
|
|
1145
|
+
expect(redeemed.status).toBe(200);
|
|
1146
|
+
const passCookie = extractCookie(redeemed);
|
|
1147
|
+
expect((await send(app, 'GET', '/me', { cookie: passCookie })).status).toBe(200);
|
|
1148
|
+
const { rows: links } = await app.db.pool.query(
|
|
1149
|
+
`SELECT session_id FROM demo_pass_sessions WHERE pass_id = $1`,
|
|
1150
|
+
[opened.id]
|
|
1151
|
+
);
|
|
1152
|
+
expect(links).toHaveLength(1);
|
|
1153
|
+
const sessionId = links[0].session_id as string;
|
|
1154
|
+
|
|
1155
|
+
// Boundaries: another owner's pass for another person here, and o2's pass
|
|
1156
|
+
// in o2's other workspace.
|
|
1157
|
+
const bystanderPass = await mint(actors.owner!, { email: 'f1-bystander@example.com' });
|
|
1158
|
+
const awayRes = await send(
|
|
1159
|
+
app,
|
|
1160
|
+
'POST',
|
|
1161
|
+
'/demo/passes',
|
|
1162
|
+
o2,
|
|
1163
|
+
{ email: 'far-demo@example.com' },
|
|
1164
|
+
{ 'x-workspace-id': o2Elsewhere }
|
|
1165
|
+
);
|
|
1166
|
+
expect(awayRes.status).toBe(201);
|
|
1167
|
+
const away = await readJson(awayRes);
|
|
1168
|
+
secrets.push(away.secret);
|
|
1169
|
+
|
|
1170
|
+
expect((await removeMember(o2MemberId)).status).toBe(200);
|
|
1171
|
+
|
|
1172
|
+
expect(await revokedAtOf(opened.id)).toBeInstanceOf(Date);
|
|
1173
|
+
expect(await revokedAtOf(idle.id)).toBeInstanceOf(Date);
|
|
1174
|
+
await expectRedeemRefused(opened.secret);
|
|
1175
|
+
await expectRedeemRefused(idle.secret);
|
|
1176
|
+
|
|
1177
|
+
// The session the pass opened before the removal is signed out, its rows gone.
|
|
1178
|
+
expect((await send(app, 'GET', '/me', { cookie: passCookie })).status).toBe(401);
|
|
1179
|
+
expect(
|
|
1180
|
+
(await app.db.pool.query(`SELECT 1 FROM demo_pass_sessions WHERE session_id = $1`, [sessionId])).rows
|
|
1181
|
+
).toHaveLength(0);
|
|
1182
|
+
expect((await app.db.pool.query(`SELECT 1 FROM session WHERE id = $1`, [sessionId])).rows).toHaveLength(
|
|
1183
|
+
0
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
// Untouched: another owner's pass here, and o2's pass in another workspace.
|
|
1187
|
+
expect(await revokedAtOf(bystanderPass.id)).toBeNull();
|
|
1188
|
+
expect((await redeem(bystanderPass.secret)).status).toBe(200);
|
|
1189
|
+
expect(await revokedAtOf(away.pass.id)).toBeNull();
|
|
1190
|
+
expect((await redeem(away.secret)).status).toBe(200);
|
|
1191
|
+
|
|
1192
|
+
// The count: the two passes o2 minted here.
|
|
1193
|
+
expect(await removalMetadata(o2MemberId)).toMatchObject({ demoPasses: 2 });
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
it('a pass minted FOR a removed person is revoked, and stays revoked once they are invited back', async () => {
|
|
1197
|
+
const person = await addMember('forPerson', 'for-person@example.com');
|
|
1198
|
+
const personMemberId = await memberIdOf(person.userId);
|
|
1199
|
+
const minted = await mint(actors.owner!, { email: person.email });
|
|
1200
|
+
|
|
1201
|
+
expect((await removeMember(personMemberId)).status).toBe(200);
|
|
1202
|
+
expect(await revokedAtOf(minted.id)).toBeInstanceOf(Date);
|
|
1203
|
+
await expectRedeemRefused(minted.secret);
|
|
1204
|
+
expect(await removalMetadata(personMemberId)).toMatchObject({ demoPasses: 1 });
|
|
1205
|
+
|
|
1206
|
+
// Invited back and accepted with the person's own session.
|
|
1207
|
+
const invited = await send(app, 'POST', '/invitations', actors.owner!, {
|
|
1208
|
+
email: person.email,
|
|
1209
|
+
role: 'member'
|
|
1210
|
+
});
|
|
1211
|
+
expect(invited.status).toBe(201);
|
|
1212
|
+
const token = ((await readJson(invited)).acceptUrl as string).split('/invite/')[1]!;
|
|
1213
|
+
expect((await send(app, 'POST', '/invitations/accept', person, { token })).status).toBe(200);
|
|
1214
|
+
const { rows } = await app.db.pool.query(`SELECT is_active FROM workspace_members WHERE id = $1`, [
|
|
1215
|
+
personMemberId
|
|
1216
|
+
]);
|
|
1217
|
+
expect(rows[0].is_active).toBe(true);
|
|
1218
|
+
|
|
1219
|
+
expect(await revokedAtOf(minted.id)).toBeInstanceOf(Date);
|
|
1220
|
+
await expectRedeemRefused(minted.secret);
|
|
1221
|
+
});
|
|
1222
|
+
});
|