@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/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Link, Page } from "@starci/grammar/common"
|
|
2
2
|
|
|
3
3
|
/** The words of a page that does not exist and the way back. */
|
|
4
4
|
type NotFoundNoticeProps = {
|
|
@@ -10,10 +10,7 @@ type NotFoundNoticeProps = {
|
|
|
10
10
|
|
|
11
11
|
/** A missing page: the message and the way back to the app's front door. */
|
|
12
12
|
export const NotFoundNotice = (props: NotFoundNoticeProps) => (
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
{props.homeLabel}
|
|
17
|
-
</TextAction>
|
|
18
|
-
</>
|
|
13
|
+
<Page header={{ title: props.title }} measure="reading">
|
|
14
|
+
<Link href={props.homeHref}>{props.homeLabel}</Link>
|
|
15
|
+
</Page>
|
|
19
16
|
)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react"
|
|
2
|
-
import { GrammarRoot,
|
|
3
|
-
import { siteShellClassNames } from "./classNames"
|
|
2
|
+
import { GrammarRoot, Link, TopBar, WorkspaceShell } from "@starci/grammar/common"
|
|
4
3
|
|
|
5
4
|
/** What an app's chrome hands the shell: the brand, its home link and the routed body. */
|
|
6
5
|
type SiteShellProps = {
|
|
@@ -14,6 +13,6 @@ type SiteShellProps = {
|
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* The document chrome both apps share: Grammar's Common boundary, the brand header (the top bar carrying the wordmark that links
|
|
17
|
-
* home), then the routed body in the shell's main landmark
|
|
16
|
+
* home), then the routed body in the shell's main landmark (each page draws its own measure).
|
|
18
17
|
*/
|
|
19
18
|
{{> fe/common/site-shell.tsx.partial}}
|
package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Link, Page } from "@starci/grammar/common"
|
|
2
2
|
|
|
3
3
|
/** The words of a missing page and the way home. */
|
|
4
4
|
type NotFoundNoticeProps = {
|
|
@@ -9,10 +9,7 @@ type NotFoundNoticeProps = {
|
|
|
9
9
|
|
|
10
10
|
/** A missing page with one destination back to the app's front door. */
|
|
11
11
|
export const NotFoundNotice = (props: NotFoundNoticeProps) => (
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
{props.homeLabel}
|
|
16
|
-
</TextAction>
|
|
17
|
-
</>
|
|
12
|
+
<Page header={{ title: props.title }} measure="reading">
|
|
13
|
+
<Link href={props.homeHref}>{props.homeLabel}</Link>
|
|
14
|
+
</Page>
|
|
18
15
|
)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react"
|
|
2
|
-
import { GrammarRoot,
|
|
3
|
-
import { siteShellClassNames } from "./classNames"
|
|
2
|
+
import { GrammarRoot, Link, TopBar, WorkspaceShell } from "@starci/grammar/common"
|
|
4
3
|
|
|
5
4
|
/** What the app chrome hands the shell. */
|
|
6
5
|
type SiteShellProps = {
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# Development prerequisite record
|
|
2
|
-
|
|
3
|
-
Status: pending owner execution. This record states intended configuration and contains no completed operational receipt.
|
|
4
|
-
|
|
5
|
-
## DNS
|
|
6
|
-
|
|
7
|
-
The development operator replaces `dev.{{project}}.example.invalid` with a locally owned development name, updates the manifest, Caddyfile and public configuration together, and resolves the name to the Swarm node. Record the actual resolver mapping and dated verification here.
|
|
8
|
-
|
|
9
|
-
## TLS
|
|
10
|
-
|
|
11
|
-
The local development route uses HTTP on ingress port 80 with `auto_https off`. The development operator owns this local-only choice and both external ingress volumes. Record any change to locally issued TLS, certificate custody and verification here before exposing the development stack outside the local network.
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# Development recovery record
|
|
2
|
-
|
|
3
|
-
Status: pending owner execution. No backup or restore has been run by scaffolding.
|
|
4
|
-
|
|
5
|
-
The development operator owns recovery of the db service's volume `{{project}}-dev-db-data` and both external ingress volumes, `{{project}}-dev-ingress-data` and `{{project}}-dev-ingress-config-data`. Retain seven daily encrypted off-host exports or snapshots for each target.
|
|
6
|
-
|
|
7
|
-
Before relying on the backup policy, restore a database export and both volume snapshots into isolated infrastructure. Record the actual off-host destination, encryption custody, backup artifact digests, restoration time and operator. Verify database connectivity and the API readiness endpoint, then verify the restored ingress configuration and state. Attach the dated result here without credentials.
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Illustrative inventory only. Replace application digests with verified published images.
|
|
2
|
-
schema: starci/images-lock@1
|
|
3
|
-
environment: dev
|
|
4
|
-
status: pending-owner-release
|
|
5
|
-
images:
|
|
6
|
-
api: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
7
|
-
web: "{{project}}/web:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
8
|
-
ingress: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
# Illustrative development model. Replace all-zero application digests with verified published release digests before use.
|
|
2
|
-
services:
|
|
3
|
-
api:
|
|
4
|
-
image: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
5
|
-
environment:
|
|
6
|
-
NODE_ENV: development
|
|
7
|
-
PORT: "3001"
|
|
8
|
-
HTTP_SECURITY_ALLOWED_ORIGINS: http://dev.{{project}}.example.invalid
|
|
9
|
-
PRIMARY_DB_URL_FILE: /run/secrets/primary-db-url
|
|
10
|
-
PRIMARY_DB_SCHEMA: public
|
|
11
|
-
GOOGLE_CLIENT_ID: REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID
|
|
12
|
-
GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google-client-secret
|
|
13
|
-
GOOGLE_CALLBACK_URL: http://dev.{{project}}.example.invalid/auth/google/callback
|
|
14
|
-
AUTH_JWT_ISSUER: http://dev.{{project}}.example.invalid
|
|
15
|
-
AUTH_JWT_AUDIENCE: {{project}}
|
|
16
|
-
AUTH_JWT_KEY_ID: dev-1
|
|
17
|
-
AUTH_JWT_PRIVATE_KEY_FILE: /run/secrets/auth-jwt-private-key
|
|
18
|
-
STORAGE_ENDPOINT: https://storage.{{project}}.example.invalid
|
|
19
|
-
STORAGE_REGION: us-east-1
|
|
20
|
-
STORAGE_BUCKET: {{project}}-dev
|
|
21
|
-
STORAGE_ACCESS_KEY_ID_FILE: /run/secrets/storage-access-key-id
|
|
22
|
-
STORAGE_SECRET_ACCESS_KEY_FILE: /run/secrets/storage-secret-access-key
|
|
23
|
-
secrets:
|
|
24
|
-
- source: primary-db-url
|
|
25
|
-
target: primary-db-url
|
|
26
|
-
- source: google-client-secret
|
|
27
|
-
target: google-client-secret
|
|
28
|
-
- source: auth-jwt-private-key
|
|
29
|
-
target: auth-jwt-private-key
|
|
30
|
-
- source: storage-access-key-id
|
|
31
|
-
target: storage-access-key-id
|
|
32
|
-
- source: storage-secret-access-key
|
|
33
|
-
target: storage-secret-access-key
|
|
34
|
-
networks: [app]
|
|
35
|
-
healthcheck:
|
|
36
|
-
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
37
|
-
interval: 30s
|
|
38
|
-
timeout: 5s
|
|
39
|
-
start_period: 20s
|
|
40
|
-
retries: 3
|
|
41
|
-
deploy:
|
|
42
|
-
replicas: 1
|
|
43
|
-
placement:
|
|
44
|
-
constraints: ["node.labels.{{project}} == dev"]
|
|
45
|
-
update_config:
|
|
46
|
-
parallelism: 1
|
|
47
|
-
order: start-first
|
|
48
|
-
failure_action: rollback
|
|
49
|
-
monitor: 30s
|
|
50
|
-
# Stateless and served behind the ingress: roll back start-first so the serving task stays up until the old release is healthy.
|
|
51
|
-
rollback_config:
|
|
52
|
-
parallelism: 1
|
|
53
|
-
order: start-first
|
|
54
|
-
restart_policy:
|
|
55
|
-
condition: on-failure
|
|
56
|
-
delay: 5s
|
|
57
|
-
web:
|
|
58
|
-
image: "{{project}}/web:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
59
|
-
environment:
|
|
60
|
-
NODE_ENV: development
|
|
61
|
-
PORT: "3000"
|
|
62
|
-
HOSTNAME: 0.0.0.0
|
|
63
|
-
NEXT_PUBLIC_SITE_URL: http://dev.{{project}}.example.invalid
|
|
64
|
-
networks: [app]
|
|
65
|
-
healthcheck:
|
|
66
|
-
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
67
|
-
interval: 30s
|
|
68
|
-
timeout: 5s
|
|
69
|
-
start_period: 20s
|
|
70
|
-
retries: 3
|
|
71
|
-
deploy:
|
|
72
|
-
replicas: 1
|
|
73
|
-
placement:
|
|
74
|
-
constraints: ["node.labels.{{project}} == dev"]
|
|
75
|
-
update_config:
|
|
76
|
-
parallelism: 1
|
|
77
|
-
order: start-first
|
|
78
|
-
failure_action: rollback
|
|
79
|
-
monitor: 30s
|
|
80
|
-
# Stateless and served behind the ingress: roll back start-first so the serving task stays up until the old release is healthy.
|
|
81
|
-
rollback_config:
|
|
82
|
-
parallelism: 1
|
|
83
|
-
order: start-first
|
|
84
|
-
restart_policy:
|
|
85
|
-
condition: on-failure
|
|
86
|
-
delay: 5s
|
|
87
|
-
db:
|
|
88
|
-
image: "{{project}}/db:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
89
|
-
environment:
|
|
90
|
-
POSTGRES_DB: postgres
|
|
91
|
-
POSTGRES_PASSWORD_FILE: /run/secrets/db-password
|
|
92
|
-
secrets:
|
|
93
|
-
- source: db-password
|
|
94
|
-
target: db-password
|
|
95
|
-
volumes:
|
|
96
|
-
- db-data:/var/lib/postgresql/data
|
|
97
|
-
networks: [app]
|
|
98
|
-
healthcheck:
|
|
99
|
-
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1"]
|
|
100
|
-
interval: 10s
|
|
101
|
-
timeout: 5s
|
|
102
|
-
start_period: 30s
|
|
103
|
-
retries: 5
|
|
104
|
-
deploy:
|
|
105
|
-
replicas: 1
|
|
106
|
-
placement:
|
|
107
|
-
constraints: ["node.labels.{{project}} == dev"]
|
|
108
|
-
update_config:
|
|
109
|
-
parallelism: 1
|
|
110
|
-
order: stop-first
|
|
111
|
-
failure_action: rollback
|
|
112
|
-
monitor: 30s
|
|
113
|
-
# The database owns a data volume and is single-writer: never run two at once, so rollback stays stop-first.
|
|
114
|
-
rollback_config:
|
|
115
|
-
parallelism: 1
|
|
116
|
-
order: stop-first
|
|
117
|
-
restart_policy:
|
|
118
|
-
condition: on-failure
|
|
119
|
-
delay: 5s
|
|
120
|
-
ingress:
|
|
121
|
-
image: caddy:2.10.2@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
|
|
122
|
-
configs:
|
|
123
|
-
- source: ingress-config
|
|
124
|
-
target: /etc/caddy/Caddyfile
|
|
125
|
-
ports:
|
|
126
|
-
- { target: 80, published: 80, protocol: tcp, mode: ingress }
|
|
127
|
-
volumes:
|
|
128
|
-
- ingress-data:/data
|
|
129
|
-
- ingress-config-data:/config
|
|
130
|
-
networks: [app]
|
|
131
|
-
healthcheck:
|
|
132
|
-
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
|
|
133
|
-
interval: 30s
|
|
134
|
-
timeout: 5s
|
|
135
|
-
retries: 3
|
|
136
|
-
deploy:
|
|
137
|
-
replicas: 1
|
|
138
|
-
placement:
|
|
139
|
-
constraints: ["node.labels.{{project}} == dev"]
|
|
140
|
-
update_config:
|
|
141
|
-
parallelism: 1
|
|
142
|
-
order: stop-first
|
|
143
|
-
failure_action: rollback
|
|
144
|
-
monitor: 30s
|
|
145
|
-
# The ingress owns published ports and a certificate volume: never run two at once, so rollback stays stop-first.
|
|
146
|
-
rollback_config:
|
|
147
|
-
parallelism: 1
|
|
148
|
-
order: stop-first
|
|
149
|
-
restart_policy:
|
|
150
|
-
condition: on-failure
|
|
151
|
-
delay: 5s
|
|
152
|
-
networks:
|
|
153
|
-
app:
|
|
154
|
-
driver: overlay
|
|
155
|
-
secrets:
|
|
156
|
-
primary-db-url:
|
|
157
|
-
external: true
|
|
158
|
-
name: "{{project}}-dev-primary-db-url-v1"
|
|
159
|
-
db-password:
|
|
160
|
-
external: true
|
|
161
|
-
name: "{{project}}-dev-db-password-v1"
|
|
162
|
-
google-client-secret:
|
|
163
|
-
external: true
|
|
164
|
-
name: "{{project}}-dev-google-client-secret-v1"
|
|
165
|
-
auth-jwt-private-key:
|
|
166
|
-
external: true
|
|
167
|
-
name: "{{project}}-dev-auth-jwt-private-key-v1"
|
|
168
|
-
storage-access-key-id:
|
|
169
|
-
external: true
|
|
170
|
-
name: "{{project}}-dev-storage-access-key-id-v1"
|
|
171
|
-
storage-secret-access-key:
|
|
172
|
-
external: true
|
|
173
|
-
name: "{{project}}-dev-storage-secret-access-key-v1"
|
|
174
|
-
configs:
|
|
175
|
-
ingress-config:
|
|
176
|
-
file: ./Caddyfile
|
|
177
|
-
volumes:
|
|
178
|
-
db-data:
|
|
179
|
-
external: true
|
|
180
|
-
name: "{{project}}-dev-db-data"
|
|
181
|
-
ingress-data:
|
|
182
|
-
external: true
|
|
183
|
-
name: "{{project}}-dev-ingress-data"
|
|
184
|
-
ingress-config-data:
|
|
185
|
-
external: true
|
|
186
|
-
name: "{{project}}-dev-ingress-config-data"
|
|
@@ -1,22 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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/passport/passport.cookie.spec.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { Test } from "@nestjs/testing"
|
|
2
|
-
import type { Request, Response } from "express"
|
|
3
|
-
import { mock } from "@starci/jest-preset"
|
|
4
|
-
import { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } from "./passport.cookie"
|
|
5
|
-
import { CookieStateStore } from "./strategies/oauth-state.store"
|
|
6
|
-
|
|
7
|
-
const COOKIE: unique symbol = Symbol("passport.cookie")
|
|
8
|
-
|
|
9
|
-
const OPTIONS = { name: "refresh", path: "/auth/refresh", ttlSeconds: 600 }
|
|
10
|
-
|
|
11
|
-
const build = async () => {
|
|
12
|
-
const moduleRef = await Test.createTestingModule({
|
|
13
|
-
providers: [{ provide: COOKIE, useValue: { clearRefreshCookie, newRefreshToken, refreshTokenHash, setRefreshCookie } }],
|
|
14
|
-
}).compile()
|
|
15
|
-
return moduleRef.get<{
|
|
16
|
-
clearRefreshCookie: typeof clearRefreshCookie
|
|
17
|
-
newRefreshToken: typeof newRefreshToken
|
|
18
|
-
refreshTokenHash: typeof refreshTokenHash
|
|
19
|
-
setRefreshCookie: typeof setRefreshCookie
|
|
20
|
-
}>(COOKIE)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe("refresh tokens", () => {
|
|
24
|
-
it("stores the sha256 of a token, never the token", async () => {
|
|
25
|
-
const subject = await build()
|
|
26
|
-
|
|
27
|
-
expect(subject.refreshTokenHash("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it("mints a 256-bit base64url token paired with its own hash", async () => {
|
|
31
|
-
const subject = await build()
|
|
32
|
-
const minted = subject.newRefreshToken()
|
|
33
|
-
|
|
34
|
-
expect(Buffer.from(minted.token, "base64url")).toHaveLength(32)
|
|
35
|
-
expect(minted.hash).toBe(subject.refreshTokenHash(minted.token))
|
|
36
|
-
expect(subject.newRefreshToken().token).not.toBe(minted.token)
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
describe("refresh cookie", () => {
|
|
41
|
-
it("sets the rotated token httpOnly, Secure, SameSite=Strict on the refresh path for its lifetime", async () => {
|
|
42
|
-
const subject = await build()
|
|
43
|
-
const response = mock<Response>()
|
|
44
|
-
|
|
45
|
-
subject.setRefreshCookie(response, OPTIONS, "token-1")
|
|
46
|
-
|
|
47
|
-
expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", maxAge: 600_000 })
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it("clears the cookie with the attributes it was set with", async () => {
|
|
51
|
-
const subject = await build()
|
|
52
|
-
const response = mock<Response>()
|
|
53
|
-
|
|
54
|
-
subject.clearRefreshCookie(response, OPTIONS)
|
|
55
|
-
|
|
56
|
-
expect(response.clearCookie).toHaveBeenCalledWith("refresh", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh" })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it("stays host-only: no Domain attribute is set or cleared when no cookie domain is configured", async () => {
|
|
60
|
-
const subject = await build()
|
|
61
|
-
const response = mock<Response>()
|
|
62
|
-
|
|
63
|
-
subject.setRefreshCookie(response, OPTIONS, "token-1")
|
|
64
|
-
subject.clearRefreshCookie(response, OPTIONS)
|
|
65
|
-
|
|
66
|
-
expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", expect.not.objectContaining({ domain: expect.anything() }))
|
|
67
|
-
expect(response.clearCookie).toHaveBeenCalledWith("refresh", expect.not.objectContaining({ domain: expect.anything() }))
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it("sets the rotated token with Domain=<cookieDomain> so a sibling host can forward it", async () => {
|
|
71
|
-
const subject = await build()
|
|
72
|
-
const response = mock<Response>()
|
|
73
|
-
|
|
74
|
-
subject.setRefreshCookie(response, { ...OPTIONS, cookieDomain: "example.com" }, "token-1")
|
|
75
|
-
|
|
76
|
-
expect(response.cookie).toHaveBeenCalledWith("refresh", "token-1", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", domain: "example.com", maxAge: 600_000 })
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it("clears the cookie with the same Domain it was set with, or the browser keeps it", async () => {
|
|
80
|
-
const subject = await build()
|
|
81
|
-
const response = mock<Response>()
|
|
82
|
-
|
|
83
|
-
subject.clearRefreshCookie(response, { ...OPTIONS, cookieDomain: "example.com" })
|
|
84
|
-
|
|
85
|
-
expect(response.clearCookie).toHaveBeenCalledWith("refresh", { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh", domain: "example.com" })
|
|
86
|
-
})
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
describe("oauth state cookie", () => {
|
|
90
|
-
const request = (response: Response): Request => ({ res: response, headers: {} }) as unknown as Request
|
|
91
|
-
|
|
92
|
-
it("is host-only without a cookie domain and carries Domain=<cookieDomain> with one, on set and on clear", () => {
|
|
93
|
-
const hostOnly = mock<Response>()
|
|
94
|
-
const shared = mock<Response>()
|
|
95
|
-
|
|
96
|
-
new CookieStateStore("/auth/google/callback").store(request(hostOnly), "verifier", undefined, undefined, () => undefined)
|
|
97
|
-
new CookieStateStore("/auth/google/callback", "example.com").store(request(shared), "verifier", undefined, undefined, () => undefined)
|
|
98
|
-
new CookieStateStore("/auth/google/callback", "example.com").verify(request(shared), "state", () => undefined)
|
|
99
|
-
|
|
100
|
-
expect(hostOnly.cookie).toHaveBeenCalledWith("oauth_state", expect.stringMatching(/^[A-Za-z0-9_-]{32}[.]verifier$/), expect.not.objectContaining({ domain: expect.anything() }))
|
|
101
|
-
expect(shared.cookie).toHaveBeenCalledWith("oauth_state", expect.stringMatching(/^[A-Za-z0-9_-]{32}[.]verifier$/), expect.objectContaining({ domain: "example.com" }))
|
|
102
|
-
expect(shared.clearCookie).toHaveBeenCalledWith("oauth_state", expect.objectContaining({ domain: "example.com" }))
|
|
103
|
-
})
|
|
104
|
-
})
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
/** `Domain=<cookieDomain>` when the app configured one; absent (host-only) otherwise. Setting and clearing must agree on it. */
|
|
15
|
-
export const domainAttribute = (cookieDomain: string | undefined): Pick<CookieOptions, "domain"> =>
|
|
16
|
-
cookieDomain === undefined ? {} : { domain: cookieDomain }
|
|
17
|
-
|
|
18
|
-
const attributes = (options: RefreshCookieOptions): CookieOptions => ({
|
|
19
|
-
httpOnly: true,
|
|
20
|
-
secure: true,
|
|
21
|
-
sameSite: "strict",
|
|
22
|
-
path: options.path,
|
|
23
|
-
...domainAttribute(options.cookieDomain),
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
/** Sets the rotated refresh token: httpOnly, Secure, SameSite=Strict and sent to the refresh door only. */
|
|
27
|
-
export const setRefreshCookie = (response: Response, options: RefreshCookieOptions, token: string): void => {
|
|
28
|
-
response.cookie(options.name, token, { ...attributes(options), maxAge: options.ttlSeconds * 1000 })
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Clears the refresh cookie on sign-out or on a detected reuse. */
|
|
32
|
-
export const clearRefreshCookie = (response: Response, options: RefreshCookieOptions): void => {
|
|
33
|
-
response.clearCookie(options.name, attributes(options))
|
|
34
|
-
}
|
package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { Test } from "@nestjs/testing"
|
|
2
|
-
import { hashPassword, verifyPassword } from "./passport.password"
|
|
3
|
-
|
|
4
|
-
const PASSWORD: unique symbol = Symbol("passport.password")
|
|
5
|
-
|
|
6
|
-
const build = async () => {
|
|
7
|
-
const moduleRef = await Test.createTestingModule({
|
|
8
|
-
providers: [{ provide: PASSWORD, useValue: { hashPassword, verifyPassword } }],
|
|
9
|
-
}).compile()
|
|
10
|
-
return moduleRef.get<{ hashPassword: typeof hashPassword; verifyPassword: typeof verifyPassword }>(PASSWORD)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe("hashPassword and verifyPassword", () => {
|
|
14
|
-
it("hashes at bcrypt cost 12 and verifies only the password it hashed", async () => {
|
|
15
|
-
const subject = await build()
|
|
16
|
-
const stored = await subject.hashPassword("correct horse")
|
|
17
|
-
|
|
18
|
-
expect(stored.slice(0, 7)).toBe("$2b$12$")
|
|
19
|
-
expect(await subject.verifyPassword("correct horse", stored)).toBe(true)
|
|
20
|
-
expect(await subject.verifyPassword("wrong horse", stored)).toBe(false)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it("never matches an account that has no password hash", async () => {
|
|
24
|
-
const subject = await build()
|
|
25
|
-
|
|
26
|
-
expect(await subject.verifyPassword("correct horse", null)).toBe(false)
|
|
27
|
-
})
|
|
28
|
-
})
|
|
@@ -1,11 +0,0 @@
|
|
|
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)
|
package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { Test } from "@nestjs/testing"
|
|
2
|
-
import { exportPKCS8, generateKeyPair, SignJWT } from "jose"
|
|
3
|
-
import { Secret } from "@modules/platform/config"
|
|
4
|
-
import { PassportErrorCode } from "./errors/passport.error"
|
|
5
|
-
import { createTokenKit, tokenKit } from "./passport.tokens"
|
|
6
|
-
|
|
7
|
-
const TOKENS: unique symbol = Symbol("passport.tokens")
|
|
8
|
-
|
|
9
|
-
type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>["privateKey"]
|
|
10
|
-
|
|
11
|
-
const ISSUER = "https://api.example.test"
|
|
12
|
-
const AUDIENCE = "app"
|
|
13
|
-
|
|
14
|
-
const build = async () => {
|
|
15
|
-
const moduleRef = await Test.createTestingModule({
|
|
16
|
-
providers: [{ provide: TOKENS, useValue: { createTokenKit, tokenKit } }],
|
|
17
|
-
}).compile()
|
|
18
|
-
return moduleRef.get<{ createTokenKit: typeof createTokenKit; tokenKit: typeof tokenKit }>(TOKENS)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const keyPair = async () => {
|
|
22
|
-
const { privateKey } = await generateKeyPair("ES256", { extractable: true })
|
|
23
|
-
return { privateKey, options: { issuer: ISSUER, audience: AUDIENCE, privateKey: new Secret(await exportPKCS8(privateKey)), keyId: "k1", accessTtlSeconds: 60 } }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A token the kit's own key signs with the given claims, so only the claim checks can refuse it. */
|
|
27
|
-
const ownSigned = (privateKey: SigningKey, claims: Record<string, unknown>, { subject = "user-1", expires = true } = {}) => {
|
|
28
|
-
const jwt = new SignJWT(claims).setProtectedHeader({ alg: "ES256", kid: "k1" }).setIssuer(ISSUER).setAudience(AUDIENCE).setIssuedAt()
|
|
29
|
-
if (subject !== "") jwt.setSubject(subject)
|
|
30
|
-
if (expires) jwt.setExpirationTime("60s")
|
|
31
|
-
return jwt.sign(privateKey)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("createTokenKit", () => {
|
|
35
|
-
it("verifies its own token with an email against its own key set and publishes only the public key", async () => {
|
|
36
|
-
const subject = await build()
|
|
37
|
-
const { options } = await keyPair()
|
|
38
|
-
const kit = await subject.createTokenKit(options)
|
|
39
|
-
|
|
40
|
-
expect(await kit.verify(await kit.issue({ id: "user-1", email: "a@example.test" }))).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated", email: "a@example.test" } })
|
|
41
|
-
expect(kit.jwks.keys).toHaveLength(1)
|
|
42
|
-
expect(kit.jwks.keys[0]).toMatchObject({ kid: "k1", alg: "ES256", use: "sig", kty: "EC", crv: "P-256" })
|
|
43
|
-
expect(kit.jwks.keys[0]).not.toHaveProperty("d")
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it("issues a token without an email claim for a user who has none", async () => {
|
|
47
|
-
const subject = await build()
|
|
48
|
-
const { options } = await keyPair()
|
|
49
|
-
const kit = await subject.createTokenKit(options)
|
|
50
|
-
|
|
51
|
-
expect(await kit.verify(await kit.issue({ id: "user-2" }))).toEqual({ kind: "ok", value: { id: "user-2", role: "authenticated" } })
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it("refuses a token another key signed, naming the failed signature", async () => {
|
|
55
|
-
const subject = await build()
|
|
56
|
-
const kit = await subject.createTokenKit((await keyPair()).options)
|
|
57
|
-
const other = await subject.createTokenKit((await keyPair()).options)
|
|
58
|
-
|
|
59
|
-
expect(await kit.verify(await other.issue({ id: "user-1" }))).toEqual({
|
|
60
|
-
kind: "refused",
|
|
61
|
-
code: PassportErrorCode.AccessTokenInvalid,
|
|
62
|
-
params: { cause: "JWSSignatureVerificationFailed: signature verification failed" },
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it("refuses a correctly signed token whose subject, expiry or role is not the one it issues", async () => {
|
|
67
|
-
const subject = await build()
|
|
68
|
-
const { privateKey, options } = await keyPair()
|
|
69
|
-
const kit = await subject.createTokenKit(options)
|
|
70
|
-
const invalid = { kind: "refused", code: PassportErrorCode.AccessTokenInvalid }
|
|
71
|
-
|
|
72
|
-
expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated" }, { subject: "" }))).toEqual(invalid)
|
|
73
|
-
expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated" }, { expires: false }))).toEqual(invalid)
|
|
74
|
-
expect(await kit.verify(await ownSigned(privateKey, { role: "service_role" }))).toEqual(invalid)
|
|
75
|
-
expect(await kit.verify(await ownSigned(privateKey, { role: "authenticated", email: 7 }))).toEqual({ kind: "ok", value: { id: "user-1", role: "authenticated" } })
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
describe("tokenKit", () => {
|
|
80
|
-
it("signs, verifies and publishes through the one key import it starts", async () => {
|
|
81
|
-
const subject = await build()
|
|
82
|
-
const { options } = await keyPair()
|
|
83
|
-
const kit = subject.tokenKit(options)
|
|
84
|
-
|
|
85
|
-
expect(await kit.verify(await kit.issue({ id: "user-3", email: "c@example.test" }))).toEqual({ kind: "ok", value: { id: "user-3", role: "authenticated", email: "c@example.test" } })
|
|
86
|
-
expect((await kit.jwks()).keys.map((key) => key.kid)).toEqual(["k1"])
|
|
87
|
-
})
|
|
88
|
-
})
|