@starci/hfs 4.9.9 → 4.10.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/CHANGELOG.md +33 -0
- package/README.md +20 -8
- package/db/backup-receipt.mjs +10 -47
- package/db/drift.mjs +289 -0
- package/db/dump.mjs +12 -11
- package/db/entity-list.mjs +9 -0
- package/db/migrate.mjs +84 -260
- package/db/new.mjs +46 -0
- package/db/post-cutover.mjs +13 -17
- package/db/postgres.mjs +80 -0
- package/emit/contracts.mjs +5 -9
- package/lint/run.mjs +12 -2
- package/package.json +5 -3
- package/release/gate.mjs +82 -31
- package/release/index.mjs +19 -8
- package/release/push.mjs +4 -4
- package/release/records.mjs +54 -0
- package/release/urgent.mjs +129 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +54 -24
- package/runtime/knowledge/hfs/design-truths.yaml +1 -1
- package/runtime/knowledge/hfs/fe-stack.yaml +4 -11
- package/runtime/knowledge/hfs/rules.yaml +250 -157
- package/runtime/knowledge/hfs/slots.yaml +199 -174
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/idempotency.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +4 -4
- package/runtime/knowledge/patterns/be/projections.yaml +7 -5
- package/runtime/knowledge/patterns/be/queues.yaml +8 -8
- package/runtime/knowledge/patterns/be/reactors.yaml +2 -2
- package/runtime/knowledge/patterns/be/realtime.yaml +6 -6
- package/runtime/knowledge/patterns/be/saga.yaml +3 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -1
- package/runtime/modules/kernel/command-policy.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +86 -115
- package/runtime/modules/schemas/application-stacks.schema.yaml +5 -5
- package/runtime/scripts/hfs/app-wiring.mjs +23 -2
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-be.mjs → identity.mjs} +45 -15
- package/runtime/scripts/hfs/architecture/index.mjs +2 -3
- package/runtime/scripts/hfs/architecture/{supabase-ast.mjs → jwt-ast.mjs} +2 -103
- package/runtime/scripts/hfs/architecture/register-once.mjs +1 -1
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +29 -4
- package/runtime/scripts/hfs/architecture/server-action.mjs +3 -4
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +1 -1
- package/runtime/scripts/hfs/architecture/{supabase-keys.mjs → sql-keys.mjs} +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +25 -27
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +7 -3
- package/runtime/scripts/hfs/check.mjs +8 -8
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +48 -30
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -106
- package/runtime/scripts/hfs/db-restamp-verb.mjs +11 -10
- package/runtime/scripts/hfs/declaration-shape.mjs +15 -18
- package/runtime/scripts/hfs/manifest-shape.mjs +6 -5
- package/runtime/scripts/hfs/rule-params-shape.mjs +4 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-access.mjs +8 -74
- package/runtime/scripts/hfs/rules/database-baseline.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-config.mjs +6 -214
- package/runtime/scripts/hfs/rules/database-constants.mjs +8 -10
- package/runtime/scripts/hfs/rules/database-migrations.mjs +3 -3
- package/runtime/scripts/hfs/rules/database-queues.mjs +4 -5
- package/runtime/scripts/hfs/rules/database-sql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database.mjs +36 -39
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/{supabase-secrets.mjs → lite-secrets.mjs} +6 -6
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -10
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +5 -4
- package/runtime/scripts/hfs/rules/stacks-release.mjs +3 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks.mjs +1 -1
- package/runtime/scripts/hfs/slot-app-view.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +8 -2
- package/runtime/scripts/hfs/sql/pg-parse.mjs +2 -2
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/runtime/scripts/lib/swarm-stack.mjs +7 -4
- package/scaffold/add-app.mjs +2 -10
- package/scaffold/add-cli-lite.mjs +6 -6
- package/scaffold/add-compose.mjs +1 -1
- package/scaffold/add-inbox-lite.mjs +4 -8
- package/scaffold/add-plan.mjs +1 -1
- package/scaffold/add-queue-lite.mjs +16 -24
- package/scaffold/add-table.mjs +34 -26
- package/scaffold/add.mjs +2 -2
- package/scaffold/app.mjs +19 -86
- package/scaffold/lite-exports.mjs +1 -1
- package/src/main.mjs +24 -22
- package/stack/cli.mjs +10 -2
- package/stack/deploy.mjs +21 -4
- package/stack/target-binding.mjs +0 -60
- package/stack/urgent-floor.mjs +147 -0
- package/sync/ci-commands.mjs +3 -1
- package/sync/index.mjs +32 -8
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -17
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +6 -8
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +4 -1
- package/templates/app/codegen-lite/scripts/codegen.mjs +2 -39
- package/templates/app/db-bootstrap/db/bootstrap.sql +139 -0
- package/templates/app/gitignore-lite +0 -3
- package/templates/app/package-scripts/db.json +9 -0
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +84 -19
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +5 -5
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -7
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +6 -6
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +69 -6
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +17 -9
- package/templates/app/skeleton-lite/README.md +11 -11
- package/templates/app/skeleton-lite/{supabase → db}/migrations/__timestamp___baseline.sql +21 -1
- package/templates/app/tool-config-lite/prettierignore +0 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +9 -5
- package/templates/be/inbox/database-client.ts.tpl +13 -4
- package/templates/be/inbox/platform/client-operation-outcomes.sql.tpl +47 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +3 -1
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +2 -2
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +6 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +39 -7
- package/templates/be/inbox/platform/migration.sql.tpl +22 -0
- package/templates/be/inbox/service.spec.ts.tpl +2 -2
- package/templates/be/inbox/service.ts.tpl +1 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +156 -0
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +114 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +51 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +62 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +20 -0
- package/templates/be/patterns/queues/platform-lite/queue-outbox.sql.tpl +35 -0
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +16 -5
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +8 -8
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +3 -3
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +2 -2
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -7
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +15 -1
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +10 -9
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +34 -18
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +29 -8
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +80 -4
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +39 -11
- package/templates/be/skeleton-lite/apps/api/Dockerfile +0 -1
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +9 -5
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +7 -4
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +4 -4
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +3 -3
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +9 -13
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +4 -2
- package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +2 -2
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +3 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +4 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +23 -13
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +25 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +9 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +35 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +23 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +22 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +63 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +11 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +32 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +69 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +37 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +36 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +59 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.sql.ts +19 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +6 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/migration-runner.service.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/primary.config.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +18 -5
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +1 -1
- package/templates/be/table/entities.index.ts.tpl +4 -0
- package/templates/be/table/entity.ts.tpl +21 -0
- package/templates/be/table/index.ts.tpl +1 -0
- package/templates/be/table/module.ts.tpl +1 -1
- package/templates/be/table/service.api.ts.tpl +10 -11
- package/templates/be/table/service.ts.tpl +5 -6
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +30 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +46 -14
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +31 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +2 -5
- package/db/local-migrate.mjs +0 -194
- package/db/reset.mjs +0 -125
- package/emit/db-types.mjs +0 -271
- package/runtime/scripts/api/supabase/database-dump.mjs +0 -5
- package/runtime/scripts/api/supabase/database-push.mjs +0 -5
- package/runtime/scripts/api/supabase/database-query.mjs +0 -5
- package/runtime/scripts/api/supabase/lib.mjs +0 -55
- package/runtime/scripts/api/supabase/migration-repair.mjs +0 -5
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +0 -50
- package/runtime/scripts/hfs/architecture/supabase.mjs +0 -226
- package/scaffold/supabase-ports.mjs +0 -26
- package/templates/app/package-scripts/supabase.json +0 -7
- package/templates/app/skeleton-lite/supabase/config.toml +0 -48
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +0 -5
- package/templates/be/inbox/platform/inbox/persistence/inbox.rows.ts.tpl +0 -33
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +0 -33
- package/templates/be/inbox/sql.ts.tpl +0 -16
- package/templates/be/patterns/jobs/platform-lite/persistence.jobs.sql.ts.tpl +0 -33
- package/templates/be/patterns/queues/platform-lite/persistence.queue.sql.ts.tpl +0 -12
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -16
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +0 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +0 -27
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.ts +0 -24
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.config.ts +0 -8
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.decorators.ts +0 -17
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.jwks.ts +0 -32
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.options.ts +0 -24
- package/templates/be/table/rows.ts.tpl +0 -4
- package/templates/be/table/sql.api.ts.tpl +0 -7
- package/templates/be/table/sql.ts.tpl +0 -4
- package/templates/fe/auth/supabase-client.ts.tpl +0 -38
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +0 -11
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +0 -18
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +0 -19
- /package/templates/app/skeleton-lite/{supabase → db}/seed.sql +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PassportOptions } from "@modules/integrations/passport"
|
|
2
|
+
import type { ObjectStorageOptions } from "@modules/integrations/object-storage"
|
|
2
3
|
import type { ServerOptions } from "@modules/platform/config"
|
|
3
4
|
import type { DatabaseConnectionOptions } from "@modules/platform/database"
|
|
4
5
|
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
@@ -9,8 +10,10 @@ export interface ApiOptions {
|
|
|
9
10
|
readonly server: ServerOptions
|
|
10
11
|
/** The origin allowlist and rate limits. */
|
|
11
12
|
readonly httpSecurity: HttpSecurityOptions
|
|
12
|
-
/** The least-privilege PostgreSQL connection to the
|
|
13
|
+
/** The least-privilege PostgreSQL connection to the app's own database. */
|
|
13
14
|
readonly database: DatabaseConnectionOptions
|
|
14
|
-
/**
|
|
15
|
-
readonly
|
|
15
|
+
/** Google OAuth, the app's own access tokens and the refresh cookie. */
|
|
16
|
+
readonly passport: PassportOptions
|
|
17
|
+
/** S3-compatible object storage. */
|
|
18
|
+
readonly objectStorage: ObjectStorageOptions
|
|
16
19
|
}
|
|
@@ -2,7 +2,7 @@ import { Module } from "@nestjs/common"
|
|
|
2
2
|
import type { DynamicModule } from "@nestjs/common"
|
|
3
3
|
import { APP_FILTER, APP_GUARD } from "@nestjs/core"
|
|
4
4
|
import { AuthGuard, CredentialGuard } from "@modules/domain/identity"
|
|
5
|
-
import {
|
|
5
|
+
import { tokenKit } from "@modules/integrations/passport"
|
|
6
6
|
import { ErrorsFilter } from "@modules/platform/errors"
|
|
7
7
|
import { OriginGuard, RateLimitGuard } from "@modules/platform/http-security"
|
|
8
8
|
import { platformModules } from "./compose/platform"
|
|
@@ -18,18 +18,18 @@ import type { ApiOptions } from "./api.options"
|
|
|
18
18
|
export class AppModule {
|
|
19
19
|
/** Builds the module from options parsed once at boot; nothing here reads the environment. */
|
|
20
20
|
static register(options: ApiOptions): DynamicModule {
|
|
21
|
-
const
|
|
21
|
+
const tokens = tokenKit(options.passport.tokens)
|
|
22
22
|
return {
|
|
23
23
|
module: AppModule,
|
|
24
24
|
imports: [
|
|
25
25
|
...platformModules(options),
|
|
26
|
-
...domainModules(
|
|
26
|
+
...domainModules(tokens.verify),
|
|
27
27
|
...systemModules(),
|
|
28
28
|
...apiModules(),
|
|
29
29
|
...webhooksModules(),
|
|
30
30
|
],
|
|
31
31
|
providers: [
|
|
32
|
-
...integrationProviders(options
|
|
32
|
+
...integrationProviders(options, tokens.verify),
|
|
33
33
|
{ provide: APP_FILTER, useClass: ErrorsFilter },
|
|
34
34
|
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
|
35
35
|
{ provide: APP_GUARD, useClass: OriginGuard },
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { DynamicModule, Type } from "@nestjs/common"
|
|
2
2
|
import { IdentityModule } from "@modules/domain/identity"
|
|
3
3
|
import { LivenessModule } from "@modules/domain/liveness"
|
|
4
|
-
import type {
|
|
4
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
5
5
|
|
|
6
|
-
/** The domain capabilities of the API, with the app's
|
|
7
|
-
export const domainModules = (verifyAccessToken:
|
|
6
|
+
/** The domain capabilities of the API, with the verifier of the app's own access tokens. */
|
|
7
|
+
export const domainModules = (verifyAccessToken: VerifyAccessToken): Array<Type<unknown> | DynamicModule> => [
|
|
8
8
|
IdentityModule.register({ isGlobal: true, verifyAccessToken }),
|
|
9
9
|
LivenessModule.register({ isGlobal: true }),
|
|
10
10
|
]
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import type { Provider } from "@nestjs/common"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "@modules/integrations/supabase"
|
|
7
|
-
import type { SupabaseOptions, VerifySupabaseAccessToken } from "@modules/integrations/supabase"
|
|
2
|
+
import { OWN_TOKEN_VERIFIER, PASSPORT_OPTIONS } from "@modules/integrations/passport"
|
|
3
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
4
|
+
import { OBJECT_STORAGE, createObjectStorage } from "@modules/integrations/object-storage"
|
|
5
|
+
import type { ApiOptions } from "../api.options"
|
|
8
6
|
|
|
9
|
-
/** The
|
|
10
|
-
export const integrationProviders = (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{ provide: SUPABASE_ADMIN_CLIENT, useValue: createSupabaseAdminClient(options) },
|
|
15
|
-
{ provide: SUPABASE_ACCESS_TOKEN_VERIFIER, useValue: verifyAccessToken },
|
|
7
|
+
/** The passport and object-storage integration ports bound once for this app. */
|
|
8
|
+
export const integrationProviders = (options: ApiOptions, verifyAccessToken: VerifyAccessToken): Array<Provider> => [
|
|
9
|
+
{ provide: PASSPORT_OPTIONS, useValue: options.passport },
|
|
10
|
+
{ provide: OWN_TOKEN_VERIFIER, useValue: verifyAccessToken },
|
|
11
|
+
{ provide: OBJECT_STORAGE, useValue: createObjectStorage(options.objectStorage) },
|
|
16
12
|
]
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { DynamicModule } from "@nestjs/common"
|
|
2
2
|
import { IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES } from "@modules/domain/identity"
|
|
3
|
-
import {
|
|
3
|
+
import { PASSPORT_ERROR_KINDS } from "@modules/integrations/passport"
|
|
4
4
|
import { ClockModule } from "@modules/platform/clock"
|
|
5
5
|
import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
|
|
6
6
|
import { CqrsModule } from "@modules/platform/cqrs"
|
|
7
7
|
import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
|
|
8
|
+
import { PRIMARY_ENTITIES } from "@modules/platform/database/entities"
|
|
8
9
|
import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
|
|
9
10
|
import { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
|
|
10
11
|
import { I18nModule } from "@modules/platform/i18n"
|
|
@@ -37,7 +38,7 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
|
|
|
37
38
|
DATABASE_ERROR_KINDS,
|
|
38
39
|
HTTP_SECURITY_ERROR_KINDS,
|
|
39
40
|
IDENTITY_ERROR_KINDS,
|
|
40
|
-
|
|
41
|
+
PASSPORT_ERROR_KINDS,
|
|
41
42
|
],
|
|
42
43
|
}),
|
|
43
44
|
CqrsModule.register({ isGlobal: true }),
|
|
@@ -48,5 +49,6 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
|
|
|
48
49
|
DatabaseModule.register({
|
|
49
50
|
isGlobal: true,
|
|
50
51
|
connections: [options.database],
|
|
52
|
+
entities: PRIMARY_ENTITIES,
|
|
51
53
|
}),
|
|
52
54
|
]
|
|
@@ -5,8 +5,8 @@ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
|
|
|
5
5
|
export interface CliEnv {
|
|
6
6
|
/** The logical connection name declared in `hfs.json`. */
|
|
7
7
|
readonly name: string
|
|
8
|
-
/** The
|
|
9
|
-
readonly provider: "
|
|
8
|
+
/** The PostgreSQL provider. */
|
|
9
|
+
readonly provider: "postgres"
|
|
10
10
|
/** The least-privilege database connection URL, read through the secret source. */
|
|
11
11
|
readonly url: Secret
|
|
12
12
|
/** The isolated PostgreSQL schema. */
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { DynamicModule } from "@nestjs/common"
|
|
2
2
|
import { ClockModule } from "@modules/platform/clock"
|
|
3
3
|
import { DatabaseModule } from "@modules/platform/database"
|
|
4
|
+
import { PRIMARY_ENTITIES } from "@modules/platform/database/entities"
|
|
4
5
|
import { IdsModule } from "@modules/platform/ids"
|
|
5
6
|
import { HTTP_ERROR_KINDS, HTTP_MESSAGES, HttpModule } from "@modules/platform/http"
|
|
6
7
|
import { LoggingModule } from "@modules/platform/logging"
|
|
7
8
|
import type { CliAppOptions } from "../cli.options"
|
|
8
9
|
|
|
9
|
-
/** The shared clock, logger and least-privilege connection used by
|
|
10
|
+
/** The shared clock, logger and least-privilege connection used by the operator commands. */
|
|
10
11
|
export const platformModules = (options: CliAppOptions): Array<DynamicModule> => [
|
|
11
12
|
ClockModule.register({ isGlobal: true }),
|
|
12
13
|
IdsModule.register({ isGlobal: true }),
|
|
13
14
|
LoggingModule.register({ isGlobal: true }),
|
|
14
15
|
HttpModule.register({ isGlobal: true }),
|
|
15
|
-
DatabaseModule.register({ isGlobal: true, connections: options.connections }),
|
|
16
|
+
DatabaseModule.register({ isGlobal: true, connections: options.connections, entities: PRIMARY_ENTITIES }),
|
|
16
17
|
]
|
|
@@ -5,8 +5,8 @@ import type { Request } from "express"
|
|
|
5
5
|
import { mock } from "@starci/jest-preset"
|
|
6
6
|
import type { Principal } from "@modules/platform/cqrs"
|
|
7
7
|
import { ok, refused } from "@modules/platform/primitives"
|
|
8
|
-
import {
|
|
9
|
-
import type {
|
|
8
|
+
import { PassportErrorCode } from "@modules/integrations/passport"
|
|
9
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
10
10
|
import { AuthGuard } from "./auth.guard"
|
|
11
11
|
import type { CredentialMetadata, CredentialPrincipal } from "./credential.contracts"
|
|
12
12
|
import { IdentityErrorCode } from "./errors/identity.error"
|
|
@@ -26,7 +26,7 @@ interface GuardRequest {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const build = async (metadata: PublicMetadata | undefined, credential?: CredentialMetadata) => {
|
|
29
|
-
const verifier = mock<{ verify:
|
|
29
|
+
const verifier = mock<{ verify: VerifyAccessToken }>()
|
|
30
30
|
const reflector = mock<Reflector>()
|
|
31
31
|
reflector.getAllAndOverride.mockImplementation((key: unknown) =>
|
|
32
32
|
key === CREDENTIAL_KEY ? credential : key === PUBLIC_KEY ? metadata : undefined,
|
|
@@ -129,7 +129,7 @@ describe("AuthGuard credential doors", () => {
|
|
|
129
129
|
|
|
130
130
|
it("refuses a door that is not public when the token fails verification", async () => {
|
|
131
131
|
const { guard, verify } = await build(undefined)
|
|
132
|
-
verify.mockResolvedValue(refused(
|
|
132
|
+
verify.mockResolvedValue(refused(PassportErrorCode.AccessTokenInvalid))
|
|
133
133
|
const { context, request } = contextOf("bearer forged-token")
|
|
134
134
|
|
|
135
135
|
await expect(guard.canActivate(context)).rejects.toThrow(IdentityErrorCode.Unauthenticated)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common"
|
|
2
2
|
import type { CanActivate, ExecutionContext } from "@nestjs/common"
|
|
3
3
|
import type { Reflector } from "@nestjs/core"
|
|
4
|
-
import type {
|
|
4
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
5
5
|
import { InjectReflector } from "@modules/platform/composition"
|
|
6
6
|
import type { Principal } from "@modules/platform/cqrs"
|
|
7
7
|
import { requestOf } from "@modules/platform/http-security"
|
|
@@ -20,12 +20,12 @@ const bearerToken = (authorization: string | undefined): string | undefined => {
|
|
|
20
20
|
@Injectable()
|
|
21
21
|
/**
|
|
22
22
|
* The fourth app guard and default-deny gate: a credential door requires the matching principal from `CredentialGuard`,
|
|
23
|
-
* a public door states its reason, and every other door requires a verified
|
|
23
|
+
* a public door states its reason, and every other door requires a principal verified from the app's own access token. It never calls another guard.
|
|
24
24
|
*/
|
|
25
25
|
export class AuthGuard implements CanActivate {
|
|
26
26
|
constructor(
|
|
27
27
|
@InjectReflector() private readonly reflector: Reflector,
|
|
28
|
-
@InjectAccessTokenVerifier() private readonly verifyAccessToken:
|
|
28
|
+
@InjectAccessTokenVerifier() private readonly verifyAccessToken: VerifyAccessToken,
|
|
29
29
|
) {}
|
|
30
30
|
|
|
31
31
|
/** Verifies session tokens only for doors without public or credential metadata, then applies the admission policy. */
|
|
@@ -6,7 +6,7 @@ declare module "express-serve-static-core" {
|
|
|
6
6
|
interface Request {
|
|
7
7
|
/** The identity `CredentialGuard` authenticated on a door marked `@Credential`; never a session principal. */
|
|
8
8
|
credentialPrincipal?: CredentialPrincipal
|
|
9
|
-
/** The authenticated
|
|
9
|
+
/** The authenticated caller, when the door is not public. */
|
|
10
10
|
principal?: Principal
|
|
11
11
|
}
|
|
12
12
|
}
|
|
@@ -1,26 +1,33 @@
|
|
|
1
|
-
import { SetMetadata, createParamDecorator } from "@nestjs/common"
|
|
1
|
+
import { SetMetadata, applyDecorators, createParamDecorator } from "@nestjs/common"
|
|
2
2
|
import type { ExecutionContext } from "@nestjs/common"
|
|
3
3
|
import { injector } from "@modules/platform/composition"
|
|
4
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
5
|
-
import type {
|
|
6
|
-
import { requestOf } from "@modules/platform/http-security"
|
|
4
|
+
import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
|
|
5
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
6
|
+
import { ServerToServer, requestOf } from "@modules/platform/http-security"
|
|
7
7
|
import type { Principal } from "@modules/platform/cqrs"
|
|
8
8
|
import type { CredentialAuthenticator, CredentialMetadata } from "./credential.contracts"
|
|
9
9
|
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
10
|
+
import { PublicReason } from "./identity.contracts"
|
|
10
11
|
import type { PublicMetadata } from "./identity.contracts"
|
|
11
12
|
|
|
12
13
|
/** Token of the access-token verifier the composition root hands to identity. */
|
|
13
14
|
export const ACCESS_TOKEN_VERIFIER: unique symbol = Symbol("domain.identity.access-token-verifier")
|
|
14
15
|
|
|
15
|
-
/** Injects the access-token verifier identity was registered with. Parameter type:
|
|
16
|
-
export const InjectAccessTokenVerifier = (): TypedParameterDecorator<
|
|
17
|
-
injector<
|
|
16
|
+
/** Injects the access-token verifier identity was registered with. Parameter type: VerifyAccessToken. */
|
|
17
|
+
export const InjectAccessTokenVerifier = (): TypedParameterDecorator<VerifyAccessToken> =>
|
|
18
|
+
injector<VerifyAccessToken>(ACCESS_TOKEN_VERIFIER)
|
|
18
19
|
|
|
19
20
|
/** Metadata key of `@Public`. */
|
|
20
21
|
export const PUBLIC_KEY = "domain.identity.public"
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Opens a door to anonymous callers, stating why; every other door needs a credential or a verified session.
|
|
25
|
+
* A signed webhook proves its caller by its signature, so it owes no browser origin.
|
|
26
|
+
*/
|
|
27
|
+
export const Public = (metadata: PublicMetadata): DoorDecorator =>
|
|
28
|
+
metadata.reason === PublicReason.SignedWebhook
|
|
29
|
+
? applyDecorators(SetMetadata(PUBLIC_KEY, metadata), ServerToServer())
|
|
30
|
+
: applyDecorators(SetMetadata(PUBLIC_KEY, metadata))
|
|
24
31
|
|
|
25
32
|
/** Metadata key of `@Credential`. */
|
|
26
33
|
export const CREDENTIAL_KEY: unique symbol = Symbol("domain.identity.credential")
|
|
@@ -32,11 +39,14 @@ export const CREDENTIAL_AUTHENTICATOR: unique symbol = Symbol("domain.identity.c
|
|
|
32
39
|
export const InjectCredentialAuthenticator = (): TypedParameterDecorator<CredentialAuthenticator> =>
|
|
33
40
|
injector<CredentialAuthenticator>(CREDENTIAL_AUTHENTICATOR)
|
|
34
41
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Marks a door authenticated by its own credential; `CredentialGuard` authenticates it and `AuthGuard` admits it. The
|
|
44
|
+
* credential travels in a header no browser attaches by itself, so the door owes no browser origin.
|
|
45
|
+
*/
|
|
46
|
+
export const Credential = (metadata: CredentialMetadata): DoorDecorator =>
|
|
47
|
+
applyDecorators(SetMetadata(CREDENTIAL_KEY, metadata), ServerToServer())
|
|
38
48
|
|
|
39
|
-
/** The authenticated
|
|
49
|
+
/** The authenticated caller established by the default-deny guard. */
|
|
40
50
|
export const CurrentPrincipal = createParamDecorator((_data: unknown, context: ExecutionContext): Principal => {
|
|
41
51
|
const principal = requestOf(context).principal
|
|
42
52
|
if (!principal) throw new IdentityError({ code: IdentityErrorCode.Unauthenticated })
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Type } from "@nestjs/common"
|
|
2
|
-
import type {
|
|
2
|
+
import type { VerifyAccessToken } from "@modules/integrations/passport"
|
|
3
3
|
import type { CredentialAuthenticator } from "./credential.contracts"
|
|
4
4
|
|
|
5
5
|
/** The identity capability's credential and session verification options. */
|
|
6
6
|
export interface IdentityOptions {
|
|
7
7
|
/** The app-owned authenticator of alternate credentials; a credential door fails closed while it is absent. */
|
|
8
8
|
readonly credentialAuthenticator?: Type<CredentialAuthenticator>
|
|
9
|
-
/** Cryptographically verifies
|
|
10
|
-
readonly verifyAccessToken:
|
|
9
|
+
/** Cryptographically verifies one of the app's own access tokens before it can become a principal. */
|
|
10
|
+
readonly verifyAccessToken: VerifyAccessToken
|
|
11
11
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createObjectStorage } from "./object-storage.client"
|
|
2
|
+
export { parseObjectStorageConfig } from "./object-storage.config"
|
|
3
|
+
export { InjectObjectStorage, OBJECT_STORAGE } from "./object-storage.decorators"
|
|
4
|
+
export type { ObjectStorage, PresignedRequest, ObjectStorageOptions } from "./object-storage.options"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Secret } from "@modules/platform/config"
|
|
2
|
+
import { createObjectStorage } from "./object-storage.client"
|
|
3
|
+
|
|
4
|
+
const options = {
|
|
5
|
+
endpoint: "http://127.0.0.1:9000",
|
|
6
|
+
region: "us-east-1",
|
|
7
|
+
bucket: "app",
|
|
8
|
+
accessKeyId: new Secret("access"),
|
|
9
|
+
secretAccessKey: new Secret("secret"),
|
|
10
|
+
presignTtlSeconds: 300,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("createObjectStorage", () => {
|
|
14
|
+
it("presigns a path-style PUT of one key in the bucket, valid for the configured lifetime", async () => {
|
|
15
|
+
const storage = createObjectStorage(options, () => new Date("2026-10-29T00:00:00.000Z"))
|
|
16
|
+
const request = await storage.presignPut("users/u1/avatar.png", "image/png")
|
|
17
|
+
|
|
18
|
+
expect(new URL(request.url).pathname).toBe("/app/users/u1/avatar.png")
|
|
19
|
+
expect(new URL(request.url).searchParams.get("X-Amz-Expires")).toBe("300")
|
|
20
|
+
expect(request.expiresAt).toBe("2026-10-29T00:05:00.000Z")
|
|
21
|
+
})
|
|
22
|
+
})
|
package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
|
2
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
|
3
|
+
import type { ObjectStorage, PresignedRequest, ObjectStorageOptions } from "./object-storage.options"
|
|
4
|
+
|
|
5
|
+
/** The one S3 client of the app over S3-compatible storage (path-style, as MinIO serves it). */
|
|
6
|
+
export const createObjectStorage = (options: ObjectStorageOptions, now: () => Date = () => new Date()): ObjectStorage => {
|
|
7
|
+
const client = new S3Client({
|
|
8
|
+
endpoint: options.endpoint,
|
|
9
|
+
region: options.region,
|
|
10
|
+
forcePathStyle: true,
|
|
11
|
+
credentials: { accessKeyId: options.accessKeyId.reveal(), secretAccessKey: options.secretAccessKey.reveal() },
|
|
12
|
+
})
|
|
13
|
+
const expiresAt = (): string => new Date(now().getTime() + options.presignTtlSeconds * 1000).toISOString()
|
|
14
|
+
const presign = async (command: PutObjectCommand | GetObjectCommand): Promise<PresignedRequest> => ({
|
|
15
|
+
url: await getSignedUrl(client, command, { expiresIn: options.presignTtlSeconds }),
|
|
16
|
+
expiresAt: expiresAt(),
|
|
17
|
+
})
|
|
18
|
+
return {
|
|
19
|
+
presignPut: (key, contentType) => presign(new PutObjectCommand({ Bucket: options.bucket, Key: key, ContentType: contentType })),
|
|
20
|
+
presignGet: (key) => presign(new GetObjectCommand({ Bucket: options.bucket, Key: key })),
|
|
21
|
+
remove: async (key) => {
|
|
22
|
+
await client.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key }))
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EnvSource } from "@modules/platform/config"
|
|
2
|
+
import type { ObjectStorageOptions } from "./object-storage.options"
|
|
3
|
+
|
|
4
|
+
/** Reads the object storage configuration with no credential or endpoint defaults; secrets read `<KEY>_FILE` first. */
|
|
5
|
+
export const parseObjectStorageConfig = (env: EnvSource): ObjectStorageOptions => ({
|
|
6
|
+
endpoint: env.url("STORAGE_ENDPOINT"),
|
|
7
|
+
region: env.string("STORAGE_REGION"),
|
|
8
|
+
bucket: env.string("STORAGE_BUCKET"),
|
|
9
|
+
accessKeyId: env.secret("STORAGE_ACCESS_KEY_ID"),
|
|
10
|
+
secretAccessKey: env.secret("STORAGE_SECRET_ACCESS_KEY"),
|
|
11
|
+
presignTtlSeconds: env.int("STORAGE_PRESIGN_TTL_SECONDS", 300),
|
|
12
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { injector } from "@modules/platform/composition"
|
|
2
|
+
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { ObjectStorage } from "./object-storage.options"
|
|
4
|
+
|
|
5
|
+
/** Token of the object storage port. */
|
|
6
|
+
export const OBJECT_STORAGE: unique symbol = Symbol("integrations.object-storage")
|
|
7
|
+
|
|
8
|
+
/** Injects the object storage port. Parameter type: ObjectStorage. */
|
|
9
|
+
export const InjectObjectStorage = (): TypedParameterDecorator<ObjectStorage> => injector<ObjectStorage>(OBJECT_STORAGE)
|
package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Secret } from "@modules/platform/config"
|
|
2
|
+
|
|
3
|
+
/** S3-compatible object storage (MinIO in lite) parsed once at API boot. */
|
|
4
|
+
export interface ObjectStorageOptions {
|
|
5
|
+
/** The S3 endpoint origin. */
|
|
6
|
+
readonly endpoint: string
|
|
7
|
+
/** The region the endpoint signs for. */
|
|
8
|
+
readonly region: string
|
|
9
|
+
/** The bucket of the app's objects. */
|
|
10
|
+
readonly bucket: string
|
|
11
|
+
/** The access key id. */
|
|
12
|
+
readonly accessKeyId: Secret
|
|
13
|
+
/** The secret access key. */
|
|
14
|
+
readonly secretAccessKey: Secret
|
|
15
|
+
/** The lifetime of a presigned URL in seconds. */
|
|
16
|
+
readonly presignTtlSeconds: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One presigned request: the URL the browser calls and when it stops working. */
|
|
20
|
+
export interface PresignedRequest {
|
|
21
|
+
/** The presigned URL. */
|
|
22
|
+
readonly url: string
|
|
23
|
+
/** The expiry instant, ISO-8601. */
|
|
24
|
+
readonly expiresAt: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The object storage port: presigned PUT and GET of one key and a server-side delete. */
|
|
28
|
+
export interface ObjectStorage {
|
|
29
|
+
/** A presigned PUT of `key` with its content type fixed. */
|
|
30
|
+
presignPut(key: string, contentType: string): Promise<PresignedRequest>
|
|
31
|
+
/** A presigned GET of `key`. */
|
|
32
|
+
presignGet(key: string): Promise<PresignedRequest>
|
|
33
|
+
/** Deletes `key`. */
|
|
34
|
+
remove(key: string): Promise<void>
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DomainError } from "@modules/platform/errors"
|
|
2
|
+
import type { ErrorKind } from "@modules/platform/errors"
|
|
3
|
+
|
|
4
|
+
/** Codes of the passport integration. */
|
|
5
|
+
export enum PassportErrorCode {
|
|
6
|
+
/** The access token failed signature or required-claim verification. */
|
|
7
|
+
AccessTokenInvalid = "PASSPORT_ACCESS_TOKEN_INVALID",
|
|
8
|
+
/** The email and password do not match an account. */
|
|
9
|
+
CredentialsRefused = "PASSPORT_CREDENTIALS_REFUSED",
|
|
10
|
+
/** The OAuth state or PKCE verifier of a callback does not match the request that started it. */
|
|
11
|
+
OAuthStateInvalid = "PASSPORT_OAUTH_STATE_INVALID",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** How each passport integration code travels. */
|
|
15
|
+
export const PASSPORT_ERROR_KINDS: Record<PassportErrorCode, ErrorKind> = {
|
|
16
|
+
[PassportErrorCode.AccessTokenInvalid]: "unauthenticated",
|
|
17
|
+
[PassportErrorCode.CredentialsRefused]: "unauthenticated",
|
|
18
|
+
[PassportErrorCode.OAuthStateInvalid]: "unauthenticated",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The one error class of the passport integration. */
|
|
22
|
+
export class PassportError extends DomainError<PassportErrorCode> {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { parsePassportConfig } from "./passport.config"
|
|
2
|
+
export { OWN_TOKEN_VERIFIER, PASSPORT_OPTIONS, PASSWORD_ACCOUNTS } from "./passport.decorators"
|
|
3
|
+
export { PASSPORT_ERROR_KINDS, PassportErrorCode } from "./errors/passport.error"
|
|
4
|
+
export { createTokenKit, tokenKit } from "./passport.tokens"
|
|
5
|
+
export type { TokenKit } from "./passport.tokens"
|
|
6
|
+
export { hashPassword, verifyPassword } from "./passport.password"
|
|
7
|
+
export { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } from "./passport.cookie"
|
|
8
|
+
export { GoogleStrategy } from "./strategies/google.strategy"
|
|
9
|
+
export type { GoogleIdentity } from "./strategies/google.strategy"
|
|
10
|
+
export { LocalStrategy } from "./strategies/local.strategy"
|
|
11
|
+
export type { FindPasswordAccount, PasswordAccount } from "./strategies/local.strategy"
|
|
12
|
+
export type { OwnPrincipal, PassportOptions, VerifyAccessToken } from "./passport.options"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EnvSource } from "@modules/platform/config"
|
|
2
|
+
import type { PassportOptions } from "./passport.options"
|
|
3
|
+
|
|
4
|
+
/** Reads the passport configuration with no credential or localhost defaults; secrets read `<KEY>_FILE` first. */
|
|
5
|
+
export const parsePassportConfig = (env: EnvSource): PassportOptions => ({
|
|
6
|
+
google: {
|
|
7
|
+
clientId: env.string("GOOGLE_CLIENT_ID"),
|
|
8
|
+
clientSecret: env.secret("GOOGLE_CLIENT_SECRET"),
|
|
9
|
+
callbackUrl: env.url("GOOGLE_CALLBACK_URL"),
|
|
10
|
+
},
|
|
11
|
+
tokens: {
|
|
12
|
+
issuer: env.url("AUTH_JWT_ISSUER"),
|
|
13
|
+
audience: env.string("AUTH_JWT_AUDIENCE"),
|
|
14
|
+
privateKey: env.secret("AUTH_JWT_PRIVATE_KEY"),
|
|
15
|
+
keyId: env.string("AUTH_JWT_KEY_ID"),
|
|
16
|
+
accessTtlSeconds: env.int("AUTH_ACCESS_TTL_SECONDS", 900),
|
|
17
|
+
},
|
|
18
|
+
refresh: {
|
|
19
|
+
name: "refresh_token",
|
|
20
|
+
path: "/auth/refresh",
|
|
21
|
+
ttlSeconds: env.int("AUTH_REFRESH_TTL_SECONDS", 2_592_000),
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
2
|
+
import type { CookieOptions, Response } from "express"
|
|
3
|
+
import type { RefreshCookieOptions } from "./passport.options"
|
|
4
|
+
|
|
5
|
+
/** A fresh opaque refresh token (256 bits) and the sha256 the session store keeps instead of it. */
|
|
6
|
+
export const newRefreshToken = (): { readonly token: string; readonly hash: string } => {
|
|
7
|
+
const token = randomBytes(32).toString("base64url")
|
|
8
|
+
return { token, hash: refreshTokenHash(token) }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The sha256 a refresh token is stored and looked up by; the token itself is never stored. */
|
|
12
|
+
export const refreshTokenHash = (token: string): string => createHash("sha256").update(token).digest("hex")
|
|
13
|
+
|
|
14
|
+
const attributes = (options: RefreshCookieOptions): CookieOptions => ({
|
|
15
|
+
httpOnly: true,
|
|
16
|
+
secure: true,
|
|
17
|
+
sameSite: "strict",
|
|
18
|
+
path: options.path,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** Sets the rotated refresh token: httpOnly, Secure, SameSite=Strict and sent to the refresh door only. */
|
|
22
|
+
export const setRefreshCookie = (response: Response, options: RefreshCookieOptions, token: string): void => {
|
|
23
|
+
response.cookie(options.name, token, { ...attributes(options), maxAge: options.ttlSeconds * 1000 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Clears the refresh cookie on sign-out or on a detected reuse. */
|
|
27
|
+
export const clearRefreshCookie = (response: Response, options: RefreshCookieOptions): void => {
|
|
28
|
+
response.clearCookie(options.name, attributes(options))
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { injector } from "@modules/platform/composition"
|
|
2
|
+
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { PassportOptions, VerifyAccessToken } from "./passport.options"
|
|
4
|
+
import type { FindPasswordAccount } from "./strategies/local.strategy"
|
|
5
|
+
|
|
6
|
+
/** Token of the passport options. */
|
|
7
|
+
export const PASSPORT_OPTIONS: unique symbol = Symbol("integrations.passport.options")
|
|
8
|
+
/** Token of the verifier of the app's own access tokens. */
|
|
9
|
+
export const OWN_TOKEN_VERIFIER: unique symbol = Symbol("integrations.passport.own-token-verifier")
|
|
10
|
+
/** Token of the password-account lookup the identity domain provides. */
|
|
11
|
+
export const PASSWORD_ACCOUNTS: unique symbol = Symbol("integrations.passport.password-accounts")
|
|
12
|
+
|
|
13
|
+
/** Injects the passport options. Parameter type: PassportOptions. */
|
|
14
|
+
export const InjectPassportOptions = (): TypedParameterDecorator<PassportOptions> => injector<PassportOptions>(PASSPORT_OPTIONS)
|
|
15
|
+
|
|
16
|
+
/** Injects the own-token verifier. Parameter type: VerifyAccessToken. */
|
|
17
|
+
export const InjectOwnTokenVerifier = (): TypedParameterDecorator<VerifyAccessToken> =>
|
|
18
|
+
injector<VerifyAccessToken>(OWN_TOKEN_VERIFIER)
|
|
19
|
+
|
|
20
|
+
/** Injects the password-account lookup. Parameter type: FindPasswordAccount. */
|
|
21
|
+
export const InjectPasswordAccounts = (): TypedParameterDecorator<FindPasswordAccount> =>
|
|
22
|
+
injector<FindPasswordAccount>(PASSWORD_ACCOUNTS)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Secret } from "@modules/platform/config"
|
|
2
|
+
import type { Outcome } from "@modules/platform/primitives"
|
|
3
|
+
import type { PassportErrorCode } from "./errors/passport.error"
|
|
4
|
+
|
|
5
|
+
/** The claims admitted from one of the app's own access tokens after every cryptographic and semantic check. */
|
|
6
|
+
export interface OwnPrincipal {
|
|
7
|
+
/** The user identifier from the verified `sub` claim (a row of auth.users). */
|
|
8
|
+
readonly id: string
|
|
9
|
+
/** The verified email claim when the user has one. */
|
|
10
|
+
readonly email?: string
|
|
11
|
+
/** The role every access token the app issues carries; any other role is refused. */
|
|
12
|
+
readonly role: "authenticated"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Verifies one bearer token against the app's own key set and returns only claims that passed every check. */
|
|
16
|
+
export type VerifyAccessToken = (token: string) => Promise<Outcome<OwnPrincipal, PassportErrorCode>>
|
|
17
|
+
|
|
18
|
+
/** Issues one short-lived access token for a signed-in user. */
|
|
19
|
+
export type IssueAccessToken = (user: { readonly id: string; readonly email?: string }) => Promise<string>
|
|
20
|
+
|
|
21
|
+
/** Google OAuth 2.0 client configuration (authorization code with state and PKCE). */
|
|
22
|
+
export interface GoogleOptions {
|
|
23
|
+
/** The OAuth client id. */
|
|
24
|
+
readonly clientId: string
|
|
25
|
+
/** The OAuth client secret. */
|
|
26
|
+
readonly clientSecret: Secret
|
|
27
|
+
/** The absolute callback URL of the back end's Google callback door. */
|
|
28
|
+
readonly callbackUrl: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The app's own asymmetric access tokens and the JWKS that publishes their public key. */
|
|
32
|
+
export interface TokenOptions {
|
|
33
|
+
/** The issuer (`iss`), the app's public API origin. */
|
|
34
|
+
readonly issuer: string
|
|
35
|
+
/** The audience (`aud`) every token names. */
|
|
36
|
+
readonly audience: string
|
|
37
|
+
/** The ES256 private key in PKCS#8 PEM; its public half is served as the JWKS. */
|
|
38
|
+
readonly privateKey: Secret
|
|
39
|
+
/** The key id (`kid`) of the signing key. */
|
|
40
|
+
readonly keyId: string
|
|
41
|
+
/** The access-token lifetime in seconds. */
|
|
42
|
+
readonly accessTtlSeconds: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The rotating refresh token: an opaque value held only in an httpOnly, Secure, SameSite=Strict cookie. */
|
|
46
|
+
export interface RefreshCookieOptions {
|
|
47
|
+
/** The cookie name. */
|
|
48
|
+
readonly name: string
|
|
49
|
+
/** The path the browser sends the cookie to: the refresh door only. */
|
|
50
|
+
readonly path: string
|
|
51
|
+
/** The refresh-token lifetime in seconds. */
|
|
52
|
+
readonly ttlSeconds: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The passport integration configuration parsed once at API boot. */
|
|
56
|
+
export interface PassportOptions {
|
|
57
|
+
/** Google OAuth 2.0. */
|
|
58
|
+
readonly google: GoogleOptions
|
|
59
|
+
/** The app's own access tokens. */
|
|
60
|
+
readonly tokens: TokenOptions
|
|
61
|
+
/** The refresh cookie. */
|
|
62
|
+
readonly refresh: RefreshCookieOptions
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { compare, hash } from "bcryptjs"
|
|
2
|
+
|
|
3
|
+
/** The bcrypt cost of a new password hash; existing `$2a$`/`$2b$` hashes (auth.users.encrypted_password) verify at their own cost. */
|
|
4
|
+
const COST = 12
|
|
5
|
+
|
|
6
|
+
/** Hashes a new password with bcrypt. */
|
|
7
|
+
export const hashPassword = (password: string): Promise<string> => hash(password, COST)
|
|
8
|
+
|
|
9
|
+
/** True when `password` matches the stored bcrypt hash; a missing hash never matches. */
|
|
10
|
+
export const verifyPassword = async (password: string, stored: string | null): Promise<boolean> =>
|
|
11
|
+
stored === null ? false : compare(password, stored)
|