@starci/hfs 5.1.0 → 5.2.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 +20 -0
- package/README.md +10 -1
- package/db/postgres.mjs +9 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
- package/runtime/knowledge/hfs/fork-images.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +55 -7
- package/runtime/knowledge/hfs/slots.yaml +75 -24
- package/runtime/modules/kernel/failure-codes.yaml +62 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
- package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
- package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
- package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
- package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
- package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
- package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
- package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
- package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
- package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
- package/runtime/scripts/hfs/secret.mjs +10 -7
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
- package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-module.d.mts +16 -0
- package/scaffold/add-module.mjs +232 -0
- package/scaffold/add-queue-lite.mjs +49 -0
- package/scaffold/add.mjs +5 -0
- package/scaffold/app.mjs +7 -7
- package/scaffold/module-registry.d.mts +19 -0
- package/scaffold/module-registry.mjs +84 -0
- package/src/main.mjs +23 -2
- package/stack/cli.mjs +6 -1
- package/stack/context.d.mts +15 -0
- package/stack/context.mjs +1 -0
- package/stack/local-cli.d.mts +25 -0
- package/stack/local-cli.mjs +89 -0
- package/stack/local-config.d.mts +17 -0
- package/stack/local-config.mjs +37 -0
- package/stack/local-custody.d.mts +24 -0
- package/stack/local-custody.mjs +109 -0
- package/stack/local-doctor.d.mts +6 -0
- package/stack/local-doctor.mjs +49 -0
- package/stack/local-down.d.mts +8 -0
- package/stack/local-down.mjs +20 -0
- package/stack/local-host.d.mts +42 -0
- package/stack/local-host.mjs +163 -0
- package/stack/local-logs.d.mts +8 -0
- package/stack/local-logs.mjs +32 -0
- package/stack/local-ports.d.mts +7 -0
- package/stack/local-ports.mjs +39 -0
- package/stack/local-prepare.d.mts +22 -0
- package/stack/local-prepare.mjs +62 -0
- package/stack/local-run.d.mts +71 -0
- package/stack/local-run.mjs +200 -0
- package/stack/local-state.d.mts +35 -0
- package/stack/local-state.mjs +29 -0
- package/stack/local-status.d.mts +6 -0
- package/stack/local-status.mjs +36 -0
- package/stack/local-up.d.mts +11 -0
- package/stack/local-up.mjs +166 -0
- package/stack/local-verify.d.mts +6 -0
- package/stack/local-verify.mjs +21 -0
- package/sync/index.d.mts +13 -12
- package/sync/index.mjs +83 -28
- package/templates/app/module/jest.config.js.tpl +8 -0
- package/templates/app/module/src/index.ts.tpl +2 -0
- package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
- package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
- package/templates/app/module/src/manifest/index.ts.tpl +1 -0
- package/templates/app/module/src/public/index.ts.tpl +2 -0
- package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
- package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
- package/templates/app/module/src/template/index.ts.tpl +1 -0
- package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
- package/templates/app/module/src/workbench/index.ts.tpl +2 -0
- package/templates/app/package-scripts/package.json +1 -1
- package/templates/app/package-scripts-lite/package.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +76 -70
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
- package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
- package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
- package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
- package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
- package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
- package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
- package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
- 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 +11 -4
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
- package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
- package/templates/fe/common/site-shell.tsx.partial +3 -3
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
- package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
- package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
- package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
- package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
- package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
- package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
- package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
3
|
+
|
|
4
|
+
@Entity({ schema: "auth", name: "users" })
|
|
5
|
+
/** One person who can sign in, mapped from auth.users of db/bootstrap.sql (the GoTrue column set; only the columns sign-in reads or writes). */
|
|
6
|
+
export class AuthUserEntity {
|
|
7
|
+
/** The account id. */
|
|
8
|
+
@PrimaryColumn({ name: "id", type: "uuid" })
|
|
9
|
+
id!: string
|
|
10
|
+
|
|
11
|
+
/** The audience the row carries. */
|
|
12
|
+
@Column({ name: "aud", type: "varchar", length: 255, nullable: true })
|
|
13
|
+
aud!: string | null
|
|
14
|
+
|
|
15
|
+
/** The database role the row carries. */
|
|
16
|
+
@Column({ name: "role", type: "varchar", length: 255, nullable: true })
|
|
17
|
+
role!: string | null
|
|
18
|
+
|
|
19
|
+
/** The sign-in address, lower-cased. */
|
|
20
|
+
@Column({ name: "email", type: "varchar", length: 255, nullable: true })
|
|
21
|
+
email!: string | null
|
|
22
|
+
|
|
23
|
+
/** The bcrypt hash of the password, or null for an account that signs in with Google only. */
|
|
24
|
+
@Column({ name: "encrypted_password", type: "varchar", length: 255, nullable: true })
|
|
25
|
+
encryptedPassword!: string | null
|
|
26
|
+
|
|
27
|
+
/** When the mailbox was confirmed, or null. */
|
|
28
|
+
@Column({ name: "email_confirmed_at", type: "timestamptz", nullable: true })
|
|
29
|
+
emailConfirmedAt!: Date | null
|
|
30
|
+
|
|
31
|
+
/** The last sign-in, or null. */
|
|
32
|
+
@Column({ name: "last_sign_in_at", type: "timestamptz", nullable: true })
|
|
33
|
+
lastSignInAt!: Date | null
|
|
34
|
+
|
|
35
|
+
/** Until when the account is banned, or null. */
|
|
36
|
+
@Column({ name: "banned_until", type: "timestamptz", nullable: true })
|
|
37
|
+
bannedUntil!: Date | null
|
|
38
|
+
|
|
39
|
+
/** When the account was deleted, or null. */
|
|
40
|
+
@Column({ name: "deleted_at", type: "timestamptz", nullable: true })
|
|
41
|
+
deletedAt!: Date | null
|
|
42
|
+
|
|
43
|
+
/** True for an anonymous account, which never signs in with an address. */
|
|
44
|
+
@Column({ name: "is_anonymous", type: "boolean" })
|
|
45
|
+
isAnonymous!: boolean
|
|
46
|
+
|
|
47
|
+
/** When the account was created. */
|
|
48
|
+
@Column({ name: "created_at", type: "timestamptz", nullable: true })
|
|
49
|
+
createdAt!: Date | null
|
|
50
|
+
|
|
51
|
+
/** When the account last changed. */
|
|
52
|
+
@Column({ name: "updated_at", type: "timestamptz", nullable: true })
|
|
53
|
+
updatedAt!: Date | null
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
3
|
+
|
|
4
|
+
@Entity({ schema: "public", name: "identity_attempts" })
|
|
5
|
+
/** The attempts of one kind and address in one window, mapped from public.identity_attempts; the address is hashed in the key. */
|
|
6
|
+
export class IdentityAttemptEntity {
|
|
7
|
+
/** The kind and the digest of the address. */
|
|
8
|
+
@PrimaryColumn({ name: "key", type: "varchar", length: 128 })
|
|
9
|
+
key!: string
|
|
10
|
+
|
|
11
|
+
/** The attempts counted in the window. */
|
|
12
|
+
@Column({ name: "hits", type: "integer" })
|
|
13
|
+
hits!: number
|
|
14
|
+
|
|
15
|
+
/** When the window ends; an attempt after it opens a fresh one. */
|
|
16
|
+
@Column({ name: "reset_at", type: "timestamptz" })
|
|
17
|
+
resetAt!: Date
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
3
|
+
|
|
4
|
+
@Entity({ schema: "public", name: "identity_refresh_tokens" })
|
|
5
|
+
/** One refresh token of a device session, mapped from public.identity_refresh_tokens; only its digest is stored, and a spent token is kept for replay detection. */
|
|
6
|
+
export class IdentityRefreshTokenEntity {
|
|
7
|
+
/** The row id. */
|
|
8
|
+
@PrimaryColumn({ name: "id", type: "uuid" })
|
|
9
|
+
id!: string
|
|
10
|
+
|
|
11
|
+
/** The device session the token belongs to. */
|
|
12
|
+
@Column({ name: "session_id", type: "uuid" })
|
|
13
|
+
sessionId!: string
|
|
14
|
+
|
|
15
|
+
/** The account the session belongs to. */
|
|
16
|
+
@Column({ name: "user_id", type: "uuid" })
|
|
17
|
+
userId!: string
|
|
18
|
+
|
|
19
|
+
/** The digest of the token, unique. */
|
|
20
|
+
@Column({ name: "token_hash", type: "text" })
|
|
21
|
+
tokenHash!: string
|
|
22
|
+
|
|
23
|
+
/** When the row was written. */
|
|
24
|
+
@Column({ name: "created_at", type: "timestamptz" })
|
|
25
|
+
createdAt!: Date
|
|
26
|
+
|
|
27
|
+
/** When the token stops working. */
|
|
28
|
+
@Column({ name: "expires_at", type: "timestamptz" })
|
|
29
|
+
expiresAt!: Date
|
|
30
|
+
|
|
31
|
+
/** When the token was rotated away, or null while it is the live one. */
|
|
32
|
+
@Column({ name: "used_at", type: "timestamptz", nullable: true })
|
|
33
|
+
usedAt!: Date | null
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Column, Entity, PrimaryColumn } from "typeorm"
|
|
3
|
+
|
|
4
|
+
@Entity({ schema: "public", name: "identity_sessions" })
|
|
5
|
+
/** One device session, mapped from public.identity_sessions of the baseline migration (the tables of @starci/auth). */
|
|
6
|
+
export class IdentitySessionEntity {
|
|
7
|
+
/** The session id. */
|
|
8
|
+
@PrimaryColumn({ name: "id", type: "uuid" })
|
|
9
|
+
id!: string
|
|
10
|
+
|
|
11
|
+
/** The account the session belongs to. */
|
|
12
|
+
@Column({ name: "user_id", type: "uuid" })
|
|
13
|
+
userId!: string
|
|
14
|
+
|
|
15
|
+
/** When the session opened. */
|
|
16
|
+
@Column({ name: "created_at", type: "timestamptz" })
|
|
17
|
+
createdAt!: Date
|
|
18
|
+
|
|
19
|
+
/** When the session last refreshed. */
|
|
20
|
+
@Column({ name: "refreshed_at", type: "timestamptz" })
|
|
21
|
+
refreshedAt!: Date
|
|
22
|
+
|
|
23
|
+
/** The bounded user agent that opened the session. */
|
|
24
|
+
@Column({ name: "user_agent", type: "text", nullable: true })
|
|
25
|
+
userAgent!: string | null
|
|
26
|
+
|
|
27
|
+
/** The caller address that opened the session. */
|
|
28
|
+
@Column({ name: "ip", type: "text", nullable: true })
|
|
29
|
+
ip!: string | null
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { AuthIdentityEntity } from "./auth-identity.entity"
|
|
3
|
+
import { AuthUserEntity } from "./auth-user.entity"
|
|
4
|
+
import { IdentityAttemptEntity } from "./identity-attempt.entity"
|
|
5
|
+
import { IdentityRefreshTokenEntity } from "./identity-refresh-token.entity"
|
|
6
|
+
import { IdentitySessionEntity } from "./identity-session.entity"
|
|
7
|
+
|
|
8
|
+
export { AuthIdentityEntity, AuthUserEntity, IdentityAttemptEntity, IdentityRefreshTokenEntity, IdentitySessionEntity }
|
|
9
|
+
|
|
10
|
+
/** The entities of the identity capability, named in PRIMARY_ENTITIES of platform/database/entities.ts. */
|
|
11
|
+
export const identityEntities: ReadonlyArray<new () => object> = [
|
|
12
|
+
AuthIdentityEntity,
|
|
13
|
+
AuthUserEntity,
|
|
14
|
+
IdentityAttemptEntity,
|
|
15
|
+
IdentityRefreshTokenEntity,
|
|
16
|
+
IdentitySessionEntity,
|
|
17
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import "reflect-metadata"
|
|
3
|
+
import { Test } from "@nestjs/testing"
|
|
4
|
+
import { LessThanOrEqual, MoreThan } from "typeorm"
|
|
5
|
+
import { FakeClock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
6
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
7
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
8
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
9
|
+
import { IdentityAttemptEntity } from "./persistence/entities"
|
|
10
|
+
import { IdentityRateCounterService } from "./rate-counter.service"
|
|
11
|
+
|
|
12
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
13
|
+
const NOW = new Date(AT)
|
|
14
|
+
const RESET_AT = new Date("2026-02-03T04:20:06.000Z")
|
|
15
|
+
const WINDOW_MS = 900_000
|
|
16
|
+
|
|
17
|
+
const isIncrement = (value: unknown): value is { readonly hits: () => string } =>
|
|
18
|
+
typeof value === "object" && value !== null && "hits" in value && typeof value.hits === "function"
|
|
19
|
+
|
|
20
|
+
const build = async (entityManager: MockEntityManager) => {
|
|
21
|
+
const moduleRef = await Test.createTestingModule({
|
|
22
|
+
providers: [
|
|
23
|
+
IdentityRateCounterService,
|
|
24
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
25
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
26
|
+
],
|
|
27
|
+
}).compile()
|
|
28
|
+
return moduleRef.get(IdentityRateCounterService)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("IdentityRateCounterService.consume", () => {
|
|
32
|
+
it("counts a hit in the live window and compares it with the limit", async () => {
|
|
33
|
+
const entityManager = mockEntityManager()
|
|
34
|
+
const [counting] = queueQueryBuilders(entityManager, [{ execute: { raw: [{ hits: 3 }] } }, { execute: { raw: [{ hits: 4 }] } }])
|
|
35
|
+
const subject = await build(entityManager)
|
|
36
|
+
|
|
37
|
+
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(true)
|
|
38
|
+
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(false)
|
|
39
|
+
|
|
40
|
+
expect(counting?.callsOf("where")[0]).toEqual([{ key: "login:abc", resetAt: MoreThan(NOW) }])
|
|
41
|
+
const [patch] = counting?.callsOf("set")[0] ?? []
|
|
42
|
+
expect(isIncrement(patch)).toBe(true)
|
|
43
|
+
if (isIncrement(patch)) expect(patch.hits()).toBe("hits + 1")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("restarts an ended window at one hit", async () => {
|
|
47
|
+
const entityManager = mockEntityManager({ update: [IdentityAttemptEntity, { affected: 1 }] })
|
|
48
|
+
queueQueryBuilders(entityManager, [{ execute: { raw: [] } }])
|
|
49
|
+
const subject = await build(entityManager)
|
|
50
|
+
|
|
51
|
+
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(true)
|
|
52
|
+
|
|
53
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentityAttemptEntity, { key: "login:abc", resetAt: LessThanOrEqual(NOW) }, { hits: 1, resetAt: RESET_AT })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("opens a window when the restart reports no affected count", async () => {
|
|
57
|
+
const entityManager = mockEntityManager({ update: [IdentityAttemptEntity, {}] })
|
|
58
|
+
const [, inserting] = queueQueryBuilders(entityManager, [{ execute: { raw: [] } }, { execute: { raw: [] } }])
|
|
59
|
+
const subject = await build(entityManager)
|
|
60
|
+
|
|
61
|
+
expect(await subject.consume("login:abc", WINDOW_MS, 3)).toBe(true)
|
|
62
|
+
|
|
63
|
+
expect(inserting?.callsOf("execute")).toHaveLength(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("opens a missing window at one hit, and a limit of zero admits nobody", async () => {
|
|
67
|
+
const entityManager = mockEntityManager({ update: [IdentityAttemptEntity, { affected: 0 }] })
|
|
68
|
+
const [, inserting] = queueQueryBuilders(entityManager, [{ execute: { raw: [] } }, { execute: { raw: [] } }])
|
|
69
|
+
const subject = await build(entityManager)
|
|
70
|
+
|
|
71
|
+
expect(await subject.consume("login:abc", WINDOW_MS, 0)).toBe(false)
|
|
72
|
+
|
|
73
|
+
expect(inserting?.callsOf("values")[0]).toEqual([{ key: "login:abc", hits: 1, resetAt: RESET_AT }])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("IdentityRateCounterService.clear", () => {
|
|
78
|
+
it("forgets the window of a key", async () => {
|
|
79
|
+
const entityManager = mockEntityManager({ delete: [IdentityAttemptEntity, {}] })
|
|
80
|
+
const subject = await build(entityManager)
|
|
81
|
+
|
|
82
|
+
await subject.clear("login:abc")
|
|
83
|
+
|
|
84
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentityAttemptEntity, { key: "login:abc" })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import { LessThanOrEqual, MoreThan } from "typeorm"
|
|
4
|
+
import type { EntityManager } from "typeorm"
|
|
5
|
+
import type { RateCounter } from "@modules/integrations/passport"
|
|
6
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
7
|
+
import type { Clock } from "@modules/platform/clock"
|
|
8
|
+
import { InjectPrimaryEntityManager, returnedRows, rowShape } from "@modules/platform/database"
|
|
9
|
+
import { IdentityAttemptEntity } from "./persistence/entities"
|
|
10
|
+
|
|
11
|
+
const isCountedWindow = rowShape({ hits: "number" })
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
/** The package's rate counter over identity_attempts: a fixed window per key, counted in one statement so every replica agrees. */
|
|
15
|
+
export class IdentityRateCounterService implements RateCounter {
|
|
16
|
+
constructor(
|
|
17
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
18
|
+
@InjectClock() private readonly clock: Clock,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** Counts one hit in the window and says whether the key is still within the limit. */
|
|
22
|
+
async consume(key: string, windowMs: number, limit: number): Promise<boolean> {
|
|
23
|
+
const now = this.clock.now()
|
|
24
|
+
const counted = await this.entityManager
|
|
25
|
+
.createQueryBuilder(IdentityAttemptEntity, "attempt")
|
|
26
|
+
.update()
|
|
27
|
+
.set({ hits: () => "hits + 1" })
|
|
28
|
+
.where({ key, resetAt: MoreThan(now) })
|
|
29
|
+
.returning("hits")
|
|
30
|
+
.execute()
|
|
31
|
+
const [live] = returnedRows(counted.raw, isCountedWindow)
|
|
32
|
+
if (live !== undefined) return live.hits <= limit
|
|
33
|
+
const resetAt = new Date(now.getTime() + windowMs)
|
|
34
|
+
const restarted = await this.entityManager.update(IdentityAttemptEntity, { key, resetAt: LessThanOrEqual(now) }, { hits: 1, resetAt })
|
|
35
|
+
if ((restarted.affected ?? 0) === 0) {
|
|
36
|
+
await this.entityManager.createQueryBuilder(IdentityAttemptEntity, "attempt").insert().values({ key, hits: 1, resetAt }).orIgnore().execute()
|
|
37
|
+
}
|
|
38
|
+
return limit >= 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Forgets the window of a key. */
|
|
42
|
+
async clear(key: string): Promise<void> {
|
|
43
|
+
await this.entityManager.delete(IdentityAttemptEntity, { key })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import "reflect-metadata"
|
|
3
|
+
import { Test } from "@nestjs/testing"
|
|
4
|
+
import { LessThan, Not } from "typeorm"
|
|
5
|
+
import { fakeTransaction, mockEntityManager } from "@starci/jest-preset"
|
|
6
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
7
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
8
|
+
import { IdentityRefreshTokenEntity, IdentitySessionEntity } from "./persistence/entities"
|
|
9
|
+
import { IdentitySessionStoreService } from "./session-store.service"
|
|
10
|
+
|
|
11
|
+
const NOW = new Date("2026-02-03T04:05:06.000Z")
|
|
12
|
+
const LATER = new Date("2026-03-03T04:05:06.000Z")
|
|
13
|
+
|
|
14
|
+
const row = (usedAt: Date | null): IdentityRefreshTokenEntity => ({
|
|
15
|
+
id: "t-1",
|
|
16
|
+
sessionId: "s-1",
|
|
17
|
+
userId: "u-1",
|
|
18
|
+
tokenHash: "h-1",
|
|
19
|
+
createdAt: NOW,
|
|
20
|
+
expiresAt: LATER,
|
|
21
|
+
usedAt,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const build = async (entityManager: MockEntityManager = mockEntityManager()) => {
|
|
25
|
+
const transaction = fakeTransaction(entityManager)
|
|
26
|
+
const moduleRef = await Test.createTestingModule({
|
|
27
|
+
providers: [IdentitySessionStoreService, { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager }],
|
|
28
|
+
}).compile()
|
|
29
|
+
return { subject: moduleRef.get(IdentitySessionStoreService), entityManager, transaction }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("IdentitySessionStoreService.transaction", () => {
|
|
33
|
+
it("opens a session and stores a live token in one transaction", async () => {
|
|
34
|
+
const { subject, entityManager, transaction } = await build(mockEntityManager({ insert: [[IdentitySessionEntity, {}], [IdentityRefreshTokenEntity, {}]] }))
|
|
35
|
+
|
|
36
|
+
await subject.transaction(async (tx) => {
|
|
37
|
+
await tx.openSession({ id: "s-1", userId: "u-1", now: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
38
|
+
await tx.insertToken({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentitySessionEntity, { id: "s-1", userId: "u-1", createdAt: NOW, refreshedAt: NOW, userAgent: "agent", ip: "203.0.113.9" })
|
|
42
|
+
expect(entityManager.insert).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: null })
|
|
43
|
+
expect(transaction.commits).toBe(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("reads the token for update, marks it used and drops the expired ones", async () => {
|
|
47
|
+
const { subject, entityManager } = await build(
|
|
48
|
+
mockEntityManager({
|
|
49
|
+
findOne: [[IdentityRefreshTokenEntity, row(NOW)], [IdentityRefreshTokenEntity, null]],
|
|
50
|
+
update: [IdentityRefreshTokenEntity, {}],
|
|
51
|
+
delete: [IdentityRefreshTokenEntity, {}],
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const found = await subject.transaction((tx) => tx.findTokenForUpdate("h-1"))
|
|
56
|
+
const missing = await subject.transaction((tx) => tx.findTokenForUpdate("h-2"))
|
|
57
|
+
await subject.transaction(async (tx) => {
|
|
58
|
+
await tx.markTokenUsed("t-1", NOW)
|
|
59
|
+
await tx.deleteExpiredTokens("u-1", NOW)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(found).toEqual({ id: "t-1", sessionId: "s-1", userId: "u-1", tokenHash: "h-1", expiresAt: LATER, usedAt: NOW })
|
|
63
|
+
expect(missing).toBeNull()
|
|
64
|
+
expect(entityManager.findOne).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { where: { tokenHash: "h-1" }, lock: { mode: "pessimistic_write" } })
|
|
65
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { id: "t-1" }, { usedAt: NOW })
|
|
66
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentityRefreshTokenEntity, { userId: "u-1", expiresAt: LessThan(NOW) })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("rolls the transaction back when the work throws", async () => {
|
|
70
|
+
const { subject, transaction } = await build()
|
|
71
|
+
|
|
72
|
+
await expect(subject.transaction(() => Promise.reject(new Error("disk full")))).rejects.toThrow("disk full")
|
|
73
|
+
|
|
74
|
+
expect(transaction.rollbacks).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("IdentitySessionStoreService", () => {
|
|
79
|
+
it("finds a token outside a transaction, or none", async () => {
|
|
80
|
+
const { subject } = await build(mockEntityManager({ findOne: [[IdentityRefreshTokenEntity, row(null)], [IdentityRefreshTokenEntity, null]] }))
|
|
81
|
+
|
|
82
|
+
expect(await subject.findToken("h-1")).toMatchObject({ id: "t-1", usedAt: null })
|
|
83
|
+
expect(await subject.findToken("h-2")).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("touches, closes and closes the others of a session", async () => {
|
|
87
|
+
const { subject, entityManager } = await build(mockEntityManager({ update: [IdentitySessionEntity, {}], delete: [IdentitySessionEntity, {}] }))
|
|
88
|
+
|
|
89
|
+
await subject.touchSession("s-1", NOW)
|
|
90
|
+
await subject.closeSession("s-1")
|
|
91
|
+
await subject.closeOtherSessions("u-1", "s-1")
|
|
92
|
+
|
|
93
|
+
expect(entityManager.update).toHaveBeenCalledWith(IdentitySessionEntity, { id: "s-1" }, { refreshedAt: NOW })
|
|
94
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentitySessionEntity, { id: "s-1" })
|
|
95
|
+
expect(entityManager.delete).toHaveBeenCalledWith(IdentitySessionEntity, { userId: "u-1", id: Not("s-1") })
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import { LessThan, Not } from "typeorm"
|
|
4
|
+
import type { EntityManager } from "typeorm"
|
|
5
|
+
import type { NewSession, RefreshTokenRecord, SessionStore, SessionTransaction } from "@modules/integrations/passport"
|
|
6
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
7
|
+
import { IdentityRefreshTokenEntity, IdentitySessionEntity } from "./persistence/entities"
|
|
8
|
+
|
|
9
|
+
const recordOf = (row: IdentityRefreshTokenEntity): RefreshTokenRecord => ({
|
|
10
|
+
id: row.id,
|
|
11
|
+
sessionId: row.sessionId,
|
|
12
|
+
userId: row.userId,
|
|
13
|
+
tokenHash: row.tokenHash,
|
|
14
|
+
expiresAt: row.expiresAt,
|
|
15
|
+
usedAt: row.usedAt,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const transactionOf = (scoped: EntityManager): SessionTransaction => ({
|
|
19
|
+
openSession: async (session: NewSession) => {
|
|
20
|
+
await scoped.insert(IdentitySessionEntity, {
|
|
21
|
+
id: session.id,
|
|
22
|
+
userId: session.userId,
|
|
23
|
+
createdAt: session.now,
|
|
24
|
+
refreshedAt: session.now,
|
|
25
|
+
userAgent: session.userAgent,
|
|
26
|
+
ip: session.ip,
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
insertToken: async (token) => {
|
|
30
|
+
await scoped.insert(IdentityRefreshTokenEntity, {
|
|
31
|
+
id: token.id,
|
|
32
|
+
sessionId: token.sessionId,
|
|
33
|
+
userId: token.userId,
|
|
34
|
+
tokenHash: token.tokenHash,
|
|
35
|
+
expiresAt: token.expiresAt,
|
|
36
|
+
usedAt: null,
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
findTokenForUpdate: async (tokenHash) => {
|
|
40
|
+
const row = await scoped.findOne(IdentityRefreshTokenEntity, { where: { tokenHash }, lock: { mode: "pessimistic_write" } })
|
|
41
|
+
return row === null ? null : recordOf(row)
|
|
42
|
+
},
|
|
43
|
+
markTokenUsed: async (tokenId, at) => {
|
|
44
|
+
await scoped.update(IdentityRefreshTokenEntity, { id: tokenId }, { usedAt: at })
|
|
45
|
+
},
|
|
46
|
+
deleteExpiredTokens: async (userId, before) => {
|
|
47
|
+
await scoped.delete(IdentityRefreshTokenEntity, { userId, expiresAt: LessThan(before) })
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
@Injectable()
|
|
52
|
+
/** The package's session store over the entities of its tables: one transaction per rotation, the token row locked for update. */
|
|
53
|
+
export class IdentitySessionStoreService implements SessionStore {
|
|
54
|
+
constructor(@InjectPrimaryEntityManager() private readonly entityManager: EntityManager) {}
|
|
55
|
+
|
|
56
|
+
/** Runs the work in one database transaction. */
|
|
57
|
+
transaction<T>(work: (tx: SessionTransaction) => Promise<T>): Promise<T> {
|
|
58
|
+
return this.entityManager.transaction((scoped) => work(transactionOf(scoped)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The token with this digest, or null. */
|
|
62
|
+
async findToken(tokenHash: string): Promise<RefreshTokenRecord | null> {
|
|
63
|
+
const row = await this.entityManager.findOne(IdentityRefreshTokenEntity, { where: { tokenHash } })
|
|
64
|
+
return row === null ? null : recordOf(row)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Marks a session refreshed. */
|
|
68
|
+
async touchSession(sessionId: string, now: Date): Promise<void> {
|
|
69
|
+
await this.entityManager.update(IdentitySessionEntity, { id: sessionId }, { refreshedAt: now })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Ends one session; its tokens go with it (the foreign key cascades). */
|
|
73
|
+
async closeSession(sessionId: string): Promise<void> {
|
|
74
|
+
await this.entityManager.delete(IdentitySessionEntity, { id: sessionId })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ends every session of an account but one. */
|
|
78
|
+
async closeOtherSessions(userId: string, keepSessionId: string): Promise<void> {
|
|
79
|
+
await this.entityManager.delete(IdentitySessionEntity, { userId, id: Not(keepSessionId) })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import "reflect-metadata"
|
|
3
|
+
import { Test } from "@nestjs/testing"
|
|
4
|
+
import { mock } from "@starci/jest-preset"
|
|
5
|
+
import { AuthError, PassportError, PassportErrorCode } from "@modules/integrations/passport"
|
|
6
|
+
import type { AccessGrant, AuthDoors, AuthSessions, GoogleRedirect, HttpExchange } from "@modules/integrations/passport"
|
|
7
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
8
|
+
import { IDENTITY_AUTH_DOORS, IDENTITY_AUTH_SESSIONS } from "./identity.decorators"
|
|
9
|
+
import { IdentitySignInService } from "./sign-in.service"
|
|
10
|
+
|
|
11
|
+
const GRANT: AccessGrant = { accessToken: "token", expiresIn: 900, expiresAt: "2026-02-03T04:20:06.000Z", user: { id: "u-1", email: "a@example.test" } }
|
|
12
|
+
const REDIRECT: GoogleRedirect = { url: "https://app.example.test/", statusCode: 303 }
|
|
13
|
+
const EXCHANGE = mock<HttpExchange>()
|
|
14
|
+
|
|
15
|
+
const build = async () => {
|
|
16
|
+
const doors = mock<AuthDoors>()
|
|
17
|
+
const sessions = mock<AuthSessions>()
|
|
18
|
+
const moduleRef = await Test.createTestingModule({
|
|
19
|
+
providers: [IdentitySignInService, { provide: IDENTITY_AUTH_DOORS, useValue: doors }, { provide: IDENTITY_AUTH_SESSIONS, useValue: sessions }],
|
|
20
|
+
}).compile()
|
|
21
|
+
return { subject: moduleRef.get(IdentitySignInService), doors, sessions }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("IdentitySignInService", () => {
|
|
25
|
+
it("hands each cookie door the exchange and answers what the package answers", async () => {
|
|
26
|
+
const { subject, doors } = await build()
|
|
27
|
+
doors.login.mockResolvedValue(GRANT)
|
|
28
|
+
doors.refresh.mockResolvedValue(GRANT)
|
|
29
|
+
doors.session.mockResolvedValue(GRANT)
|
|
30
|
+
doors.logout.mockResolvedValue()
|
|
31
|
+
doors.beginGoogle.mockResolvedValue()
|
|
32
|
+
doors.completeGoogle.mockResolvedValue(REDIRECT)
|
|
33
|
+
|
|
34
|
+
expect(await subject.login(EXCHANGE)).toBe(GRANT)
|
|
35
|
+
expect(await subject.refresh(EXCHANGE)).toBe(GRANT)
|
|
36
|
+
expect(await subject.session(EXCHANGE)).toBe(GRANT)
|
|
37
|
+
await subject.logout(EXCHANGE)
|
|
38
|
+
await subject.beginGoogle(EXCHANGE)
|
|
39
|
+
expect(await subject.completeGoogle(EXCHANGE)).toBe(REDIRECT)
|
|
40
|
+
|
|
41
|
+
for (const door of [doors.login, doors.refresh, doors.session, doors.logout, doors.beginGoogle, doors.completeGoogle]) expect(door).toHaveBeenCalledWith(EXCHANGE)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("serves the public keys", async () => {
|
|
45
|
+
const { subject, sessions } = await build()
|
|
46
|
+
sessions.jwks.mockResolvedValue({ keys: [] })
|
|
47
|
+
|
|
48
|
+
expect(await subject.jwks()).toEqual({ keys: [] })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("turns a refusal of the package into the error family that owns its code", async () => {
|
|
52
|
+
const { subject, doors } = await build()
|
|
53
|
+
doors.refresh.mockRejectedValue(new AuthError("IDENTITY_REFRESH_REUSED"))
|
|
54
|
+
doors.login.mockRejectedValue(new AuthError("PASSPORT_GOOGLE_REJECTED"))
|
|
55
|
+
|
|
56
|
+
const identity: unknown = await subject.refresh(EXCHANGE).catch((error: unknown) => error)
|
|
57
|
+
const passport: unknown = await subject.login(EXCHANGE).catch((error: unknown) => error)
|
|
58
|
+
|
|
59
|
+
expect(identity).toBeInstanceOf(IdentityError)
|
|
60
|
+
expect(identity).toMatchObject({ code: IdentityErrorCode.RefreshReused })
|
|
61
|
+
expect(passport).toBeInstanceOf(PassportError)
|
|
62
|
+
expect(passport).toMatchObject({ code: PassportErrorCode.GoogleRejected })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("reads a code neither family names as unauthenticated", async () => {
|
|
66
|
+
const { subject, doors } = await build()
|
|
67
|
+
doors.login.mockRejectedValue(Object.assign(new AuthError("IDENTITY_REFRESH_REUSED"), { code: "NOT_A_CODE" }))
|
|
68
|
+
|
|
69
|
+
await expect(subject.login(EXCHANGE)).rejects.toMatchObject({ code: IdentityErrorCode.Unauthenticated })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("rethrows a failure that is not a sign-in refusal as it is", async () => {
|
|
73
|
+
const { subject, doors } = await build()
|
|
74
|
+
doors.login.mockRejectedValue(new Error("boom"))
|
|
75
|
+
|
|
76
|
+
await expect(subject.login(EXCHANGE)).rejects.toThrow("boom")
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Managed by starci app sync (slot be.identity.sign-in); do not edit: run "npx starci app sync --execute".
|
|
2
|
+
import { Injectable } from "@nestjs/common"
|
|
3
|
+
import { AuthError, PassportError, PassportErrorCode } from "@modules/integrations/passport"
|
|
4
|
+
import { IdentityError, IdentityErrorCode } from "./errors/identity.error"
|
|
5
|
+
import { InjectAuthDoors, InjectAuthSessions } from "./identity.decorators"
|
|
6
|
+
import type { AccessGrant, AuthDoors, AuthSessions, GoogleRedirect, HttpExchange, JwksDocument } from "@modules/integrations/passport"
|
|
7
|
+
|
|
8
|
+
/** The wire codes of the package that belong to the passport family (the token, key and Google legs). */
|
|
9
|
+
const PASSPORT_CODES: ReadonlyArray<PassportErrorCode> = [
|
|
10
|
+
PassportErrorCode.AccessTokenInvalid,
|
|
11
|
+
PassportErrorCode.KeyInvalid,
|
|
12
|
+
PassportErrorCode.MissingCredentials,
|
|
13
|
+
PassportErrorCode.GoogleStateInvalid,
|
|
14
|
+
PassportErrorCode.GoogleRejected,
|
|
15
|
+
PassportErrorCode.GoogleUnavailable,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/** The wire codes of the package that belong to the identity family (the session and the sign-in refusals). */
|
|
19
|
+
const IDENTITY_CODES: ReadonlyArray<IdentityErrorCode> = [
|
|
20
|
+
IdentityErrorCode.RefreshInvalid,
|
|
21
|
+
IdentityErrorCode.RefreshReused,
|
|
22
|
+
IdentityErrorCode.AccountDisabled,
|
|
23
|
+
IdentityErrorCode.TooManyAttempts,
|
|
24
|
+
IdentityErrorCode.PairRefused,
|
|
25
|
+
IdentityErrorCode.EmailNotConfirmed,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** The error of the family that owns a wire code of the package; a code neither family names reads as unauthenticated. */
|
|
29
|
+
const familyOf = (error: AuthError): IdentityError | PassportError => {
|
|
30
|
+
const passport = PASSPORT_CODES.find((each) => each === error.code)
|
|
31
|
+
if (passport !== undefined) return new PassportError({ code: passport, cause: error })
|
|
32
|
+
const identity = IDENTITY_CODES.find((each) => each === error.code) ?? IdentityErrorCode.Unauthenticated
|
|
33
|
+
return new IdentityError({ code: identity, cause: error })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Injectable()
|
|
37
|
+
/** The cookie doors of sign-in for the transport: the package's doors with its refusals turned into the passport error family the errors filter maps. */
|
|
38
|
+
export class IdentitySignInService {
|
|
39
|
+
constructor(
|
|
40
|
+
@InjectAuthDoors() private readonly doors: AuthDoors,
|
|
41
|
+
@InjectAuthSessions() private readonly sessions: AuthSessions,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
/** Email and password through the local strategy; the refresh token goes into the cookie. */
|
|
45
|
+
login(exchange: HttpExchange): Promise<AccessGrant> {
|
|
46
|
+
return this.refusing(() => this.doors.login(exchange))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rotates the cookie token and answers a new access token. */
|
|
50
|
+
refresh(exchange: HttpExchange): Promise<AccessGrant> {
|
|
51
|
+
return this.refusing(() => this.doors.refresh(exchange))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Answers a fresh access token for the cookie session without rotating the cookie. */
|
|
55
|
+
session(exchange: HttpExchange): Promise<AccessGrant> {
|
|
56
|
+
return this.refusing(() => this.doors.session(exchange))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Ends the session of the cookie and clears it. */
|
|
60
|
+
logout(exchange: HttpExchange): Promise<void> {
|
|
61
|
+
return this.refusing(() => this.doors.logout(exchange))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Sends the browser to the Google consent screen. */
|
|
65
|
+
beginGoogle(exchange: HttpExchange): Promise<void> {
|
|
66
|
+
return this.refusing(() => this.doors.beginGoogle(exchange))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Finishes the Google round trip; a failure redirects to the sign-in page instead of throwing. */
|
|
70
|
+
completeGoogle(exchange: HttpExchange): Promise<GoogleRedirect> {
|
|
71
|
+
return this.refusing(() => this.doors.completeGoogle(exchange))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The public key document access tokens verify against. */
|
|
75
|
+
jwks(): Promise<JwksDocument> {
|
|
76
|
+
return this.refusing(() => this.sessions.jwks())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async refusing<T>(work: () => Promise<T>): Promise<T> {
|
|
80
|
+
try {
|
|
81
|
+
return await work()
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
if (cause instanceof AuthError) throw familyOf(cause)
|
|
84
|
+
throw cause
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|