create-qpq-app 0.1.8 → 0.1.10
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/package.json +2 -2
- package/template/apps/qpqjs/deploy.config.json +2 -2
- package/template/apps/qpqjs/services/design/views/src/components/LandingPage/components/NavBar.tsx +5 -17
- package/template/apps/qpqjs/services/design/views/src/components/LandingPage/components/Packages.tsx +1 -2
- package/template/apps/qpqjs/services/design/views/src/components/LandingPage/landing.css +44 -0
- package/template/apps/qpqjs/services/shell/views/src/index.html +1 -1
- package/template/docusaurus/docs/actions/core/file/ask-file-generate-temporary-upload-secure-url.md +3 -1
- package/template/docusaurus/docs/actions/core/user-directory/ask-user-directory-revoke-refresh-token.md +56 -0
- package/template/docusaurus/docs/actions/core/user-directory/ask-user-directory-sign-out-user.md +52 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-apply-event-doc-event.md +6 -4
- package/template/docusaurus/docs/actions/features/event-doc/ask-apply-transient-event-doc-event.md +53 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-generate-asset-upload-url.md +3 -1
- package/template/docusaurus/docs/actions/webserver/service-function/ask-service-function-execute.md +19 -13
- package/template/docusaurus/docs/config/core/bundle-options.md +1 -1
- package/template/docusaurus/docs/config/core/user-directory.md +3 -1
- package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
- package/template/docusaurus/docs/config/features/tenant.md +12 -10
- package/template/docusaurus/docs/config/features/web-socket-queue.md +1 -1
- package/template/docusaurus/docs/config/webserver/auth-system.md +9 -1
- package/template/docusaurus/docs/core-concepts.md +35 -1
- package/template/docusaurus/docs/eslint-plugin.md +86 -0
- package/template/docusaurus/docs/getting-started.md +3 -1
- package/template/docusaurus/sidebars.ts +1 -0
- package/template/docusaurus/src/css/custom.css +44 -0
- package/template/docusaurus/static/img/favicon.svg +6 -2
- package/template/docusaurus/static/img/qpq-logo.svg +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-qpq-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Scaffold a new quidproquo app — npx create-qpq-app my-app",
|
|
5
5
|
"main": "./lib/commonjs/index.js",
|
|
6
6
|
"module": "./lib/esm/index.js",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.13.13",
|
|
54
|
-
"quidproquo-tsconfig": "0.1.
|
|
54
|
+
"quidproquo-tsconfig": "0.1.10"
|
|
55
55
|
},
|
|
56
56
|
"bin": {
|
|
57
57
|
"create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
|
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
},
|
|
10
10
|
"staging": {
|
|
11
11
|
"platform": "aws",
|
|
12
|
-
"accountId": "
|
|
12
|
+
"accountId": "061039804449",
|
|
13
13
|
"region": "us-east-1"
|
|
14
14
|
},
|
|
15
15
|
"production": {
|
|
16
16
|
"platform": "aws",
|
|
17
17
|
"accountId": "761018864142",
|
|
18
|
-
"region": "
|
|
18
|
+
"region": "ap-southeast-2"
|
|
19
19
|
},
|
|
20
20
|
"local": {
|
|
21
21
|
"platform": "docker"
|
package/template/apps/qpqjs/services/design/views/src/components/LandingPage/components/NavBar.tsx
CHANGED
|
@@ -15,25 +15,13 @@ export function NavBar() {
|
|
|
15
15
|
<header className="nav">
|
|
16
16
|
<div className="nav__inner">
|
|
17
17
|
<a className="nav__brand" href="#top">
|
|
18
|
-
<svg aria-hidden="true" className="nav__mark" viewBox="
|
|
19
|
-
<rect
|
|
20
|
-
fill="rgba(34,211,238,0.08)"
|
|
21
|
-
height="26"
|
|
22
|
-
rx="7"
|
|
23
|
-
stroke="#22d3ee"
|
|
24
|
-
strokeWidth="1.6"
|
|
25
|
-
width="26"
|
|
26
|
-
x="3"
|
|
27
|
-
y="3"
|
|
28
|
-
/>
|
|
18
|
+
<svg aria-hidden="true" className="nav__mark" viewBox="24.4 24.4 465.3 465.3">
|
|
29
19
|
<path
|
|
30
|
-
d="
|
|
31
|
-
fill="
|
|
32
|
-
stroke="#8df6ff"
|
|
33
|
-
strokeLinecap="round"
|
|
34
|
-
strokeLinejoin="round"
|
|
35
|
-
strokeWidth="2.2"
|
|
20
|
+
d="M271.42,489.64h-10.72c-128.68,0-235.74-102.93-236.29-231.61S128.97,23.86,258.03,24.41c128.68.55,231.6,107.61,231.6,236.29v11.61c0,.09-.07.16-.16.16h-109.69c-.09,0-.16-.07-.16-.16v-12.61c0-65.9-53.82-123.71-119.7-125.24-69.89-1.62-127.08,55.57-125.46,125.46,1.53,65.89,59.34,119.71,125.24,119.71h11.72c.09,0,.16.07.16.16v109.69c0,.09-.07.16-.16.16Z"
|
|
21
|
+
fill="#22d3ee"
|
|
36
22
|
/>
|
|
23
|
+
<rect fill="#8df6ff" height="106.91" width="106.91" x="268.31" y="273.22" />
|
|
24
|
+
<rect fill="#8df6ff" height="106.91" width="106.91" x="375.62" y="380.53" />
|
|
37
25
|
</svg>
|
|
38
26
|
<span className="nav__name">
|
|
39
27
|
quid<span className="nav__name-accent">pro</span>quo
|
package/template/apps/qpqjs/services/design/views/src/components/LandingPage/components/Packages.tsx
CHANGED
|
@@ -40,8 +40,7 @@ export function Packages() {
|
|
|
40
40
|
<p className="section__kicker">the grid</p>
|
|
41
41
|
<h2 className="section__title">A package for every layer</h2>
|
|
42
42
|
<p className="section__sub">
|
|
43
|
-
|
|
44
|
-
in.
|
|
43
|
+
Every layer is its own package, so you only pull in what you need.
|
|
45
44
|
</p>
|
|
46
45
|
|
|
47
46
|
<div className="package-groups">
|
|
@@ -355,6 +355,7 @@ em {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
.nav__name {
|
|
358
|
+
position: relative;
|
|
358
359
|
font-family: var(--font-display);
|
|
359
360
|
font-size: 19px;
|
|
360
361
|
font-weight: 600;
|
|
@@ -366,6 +367,49 @@ em {
|
|
|
366
367
|
color: var(--cyan);
|
|
367
368
|
}
|
|
368
369
|
|
|
370
|
+
/* Glint overlay: the same glyphs stacked over the title, transparent except
|
|
371
|
+
for a narrow band clipped to the letterforms. It rests off-canvas, then
|
|
372
|
+
makes one eased sweep across per cycle. The band is two-tone so it reads
|
|
373
|
+
on both letter colors: the white half is invisible on the near-white
|
|
374
|
+
quid/quo but lights up the cyan pro, and the cyan half does the reverse.
|
|
375
|
+
The "/ ''" marks the duplicated text as decorative; browsers without that
|
|
376
|
+
syntax skip the overlay entirely, which just means no shimmer. */
|
|
377
|
+
.nav__name::after {
|
|
378
|
+
content: 'quidproquo' / '';
|
|
379
|
+
position: absolute;
|
|
380
|
+
inset: 0;
|
|
381
|
+
color: transparent;
|
|
382
|
+
background-image: linear-gradient(
|
|
383
|
+
105deg,
|
|
384
|
+
rgba(255, 255, 255, 0) 38%,
|
|
385
|
+
rgba(255, 255, 255, 0.85) 45%,
|
|
386
|
+
rgba(34, 211, 238, 0.85) 53%,
|
|
387
|
+
rgba(34, 211, 238, 0) 60%
|
|
388
|
+
);
|
|
389
|
+
background-size: 250% 100%;
|
|
390
|
+
background-repeat: no-repeat;
|
|
391
|
+
background-position: 130% 0;
|
|
392
|
+
-webkit-background-clip: text;
|
|
393
|
+
background-clip: text;
|
|
394
|
+
animation: nav-name-shine 7s ease-in-out infinite;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@keyframes nav-name-shine {
|
|
398
|
+
0%,
|
|
399
|
+
72% {
|
|
400
|
+
background-position: 130% 0;
|
|
401
|
+
}
|
|
402
|
+
100% {
|
|
403
|
+
background-position: -130% 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
@media (prefers-reduced-motion: reduce) {
|
|
408
|
+
.nav__name::after {
|
|
409
|
+
animation: none;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
369
413
|
.nav__links {
|
|
370
414
|
display: flex;
|
|
371
415
|
gap: 26px;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<link
|
|
6
6
|
rel="icon"
|
|
7
|
-
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='
|
|
7
|
+
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='1' y='1' width='30' height='30' rx='6' fill='%23071018'/%3E%3Cg transform='translate(4.5 4.5) scale(0.0494) translate(-24.4 -24.4)'%3E%3Cpath fill='%2322d3ee' d='M271.42,489.64h-10.72c-128.68,0-235.74-102.93-236.29-231.61S128.97,23.86,258.03,24.41c128.68.55,231.6,107.61,231.6,236.29v11.61c0,.09-.07.16-.16.16h-109.69c-.09,0-.16-.07-.16-.16v-12.61c0-65.9-53.82-123.71-119.7-125.24-69.89-1.62-127.08,55.57-125.46,125.46,1.53,65.89,59.34,119.71,125.24,119.71h11.72c.09,0,.16.07.16.16v109.69c0,.09-.07.16-.16.16Z'/%3E%3Crect fill='%238df6ff' x='268.31' y='273.22' width='106.91' height='106.91'/%3E%3Crect fill='%238df6ff' x='375.62' y='380.53' width='106.91' height='106.91'/%3E%3C/g%3E%3C/svg%3E"
|
|
8
8
|
/>
|
|
9
9
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
10
10
|
<meta
|
package/template/docusaurus/docs/actions/core/file/ask-file-generate-temporary-upload-secure-url.md
CHANGED
|
@@ -8,7 +8,7 @@ description: Generate a time-limited, signed URL that lets a client upload a fil
|
|
|
8
8
|
Generates a temporary, signed URL that lets a client **upload** a single file directly to a [storage drive](../../../config/core/storage-drive.md). The bytes go straight to storage instead of through your service, which is ideal for large uploads from a browser. The URL grants write access to just that one path and stops working once it expires.
|
|
9
9
|
|
|
10
10
|
- **Action type:** `FileActionType.GenerateTemporaryUploadSecureUrl`
|
|
11
|
-
- **On AWS:** returns a SigV4 presigned S3 `PutObject` URL for the object. The expiry cannot exceed **7 days** — the maximum lifetime of a SigV4 presigned URL. If `contentType`
|
|
11
|
+
- **On AWS:** returns a SigV4 presigned S3 `PutObject` URL for the object. The expiry cannot exceed **7 days** — the maximum lifetime of a SigV4 presigned URL. If `contentType` and/or `contentDisposition` are supplied they are bound into the signature, so the upload's `Content-Type` / `Content-Disposition` headers must match, and S3 stores them so later downloads serve the object with the same headers.
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { askFileGenerateTemporaryUploadSecureUrl } from 'quidproquo-core';
|
|
@@ -34,6 +34,7 @@ function* askFileGenerateTemporaryUploadSecureUrl(
|
|
|
34
34
|
expirationMs: number,
|
|
35
35
|
advancedOptions?: {
|
|
36
36
|
contentType?: string;
|
|
37
|
+
contentDisposition?: string;
|
|
37
38
|
},
|
|
38
39
|
scope?: string,
|
|
39
40
|
): AskResponse<string>;
|
|
@@ -54,6 +55,7 @@ function* askFileGenerateTemporaryUploadSecureUrl(
|
|
|
54
55
|
| Property | Type | Default | Description |
|
|
55
56
|
| --- | --- | --- | --- |
|
|
56
57
|
| `contentType` | `string` | – | Pins the `Content-Type` of the upload into the signature. When set, the client must send a matching `Content-Type` header or the `PUT` is rejected. |
|
|
58
|
+
| `contentDisposition` | `string` | – | Pins the `Content-Disposition` of the upload into the signature (e.g. `inline` so a stored PDF previews in an `<iframe>` instead of force-downloading). When set, the client must send a matching `Content-Disposition` header, and S3 stores it so later downloads serve it back. |
|
|
57
59
|
|
|
58
60
|
## Returns
|
|
59
61
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askUserDirectoryRevokeRefreshToken
|
|
3
|
+
description: Revoke a single refresh token — the "log out this device" primitive.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askUserDirectoryRevokeRefreshToken
|
|
7
|
+
|
|
8
|
+
Revokes a single refresh token (and every access token minted from it) against a [user directory](../../../config/core/user-directory.md). This is the "log out this device" primitive: other sessions belonging to the same user, holding different refresh tokens, are unaffected.
|
|
9
|
+
|
|
10
|
+
- **Action type:** `UserDirectoryActionType.RevokeRefreshToken`
|
|
11
|
+
- **On AWS:** issues Cognito `RevokeToken`. Requires token revocation to be enabled on the app client (the CDK default); a disabled client surfaces as an unmapped error.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { askUserDirectoryRevokeRefreshToken } from 'quidproquo-core';
|
|
15
|
+
|
|
16
|
+
export function* askLogOutThisDevice(refreshToken: string) {
|
|
17
|
+
yield* askUserDirectoryRevokeRefreshToken('app-users', refreshToken);
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Signature
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
function* askUserDirectoryRevokeRefreshToken(
|
|
25
|
+
userDirectoryName: string,
|
|
26
|
+
refreshToken: string,
|
|
27
|
+
): AskResponse<void>;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Parameters
|
|
31
|
+
|
|
32
|
+
| Parameter | Type | Description |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `userDirectoryName` | `string` | Name of the directory — must match a directory declared with [defineUserDirectory](../../../config/core/user-directory.md). |
|
|
35
|
+
| `refreshToken` | `string` | The refresh token to revoke (from `AuthenticationInfo.refreshToken`). |
|
|
36
|
+
|
|
37
|
+
## Returns
|
|
38
|
+
|
|
39
|
+
`void` — the story resumes once the token has been revoked.
|
|
40
|
+
|
|
41
|
+
## Errors
|
|
42
|
+
|
|
43
|
+
| Error | Meaning |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `UserDirectoryRevokeRefreshTokenErrorTypeEnum.Unauthorized` | The refresh token is invalid or already revoked, or revocation is disabled on the app client. |
|
|
46
|
+
| `UserDirectoryRevokeRefreshTokenErrorTypeEnum.LimitExceeded` | Too many attempts; back off and retry later. |
|
|
47
|
+
|
|
48
|
+
## Notes
|
|
49
|
+
|
|
50
|
+
Access tokens already minted from the revoked refresh token remain valid until they expire — they're stateless JWTs and can't be individually invalidated. Keep `accessTokenValidityMinutes` (see [defineUserDirectory](../../../config/core/user-directory.md#parameters)) short to shrink that window.
|
|
51
|
+
|
|
52
|
+
## Related
|
|
53
|
+
|
|
54
|
+
- [askUserDirectorySignOutUser](./ask-user-directory-sign-out-user.md) — revokes **every** refresh token for the user, not just one.
|
|
55
|
+
- [askUserDirectoryRefreshToken](./ask-user-directory-refresh-token.md) — exchanges a refresh token for fresh tokens.
|
|
56
|
+
- [defineUserDirectory](../../../config/core/user-directory.md) — declares the directory.
|
package/template/docusaurus/docs/actions/core/user-directory/ask-user-directory-sign-out-user.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askUserDirectorySignOutUser
|
|
3
|
+
description: Globally sign a user out by revoking every refresh token issued to them.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askUserDirectorySignOutUser
|
|
7
|
+
|
|
8
|
+
Globally signs a user out: revokes **every** refresh token issued to the owner of the supplied access token, against a [user directory](../../../config/core/user-directory.md). Use this for a "sign out of all devices" action or as a compromise response, since a stolen/persisted refresh token stops working for every session at once.
|
|
9
|
+
|
|
10
|
+
- **Action type:** `UserDirectoryActionType.SignOutUser`
|
|
11
|
+
- **On AWS:** issues Cognito `GlobalSignOut`. Authorized by the access token itself — no user directory name or IAM is needed to identify the user, Cognito resolves it from the token.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { askUserDirectorySignOutUser } from 'quidproquo-core';
|
|
15
|
+
|
|
16
|
+
export function* askLogOutEverywhere(accessToken: string) {
|
|
17
|
+
yield* askUserDirectorySignOutUser(accessToken);
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Signature
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
function* askUserDirectorySignOutUser(accessToken: string): AskResponse<void>;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Parameters
|
|
28
|
+
|
|
29
|
+
| Parameter | Type | Description |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `accessToken` | `string` | The signed-in user's access token, identifying whose sessions to sign out. |
|
|
32
|
+
|
|
33
|
+
## Returns
|
|
34
|
+
|
|
35
|
+
`void` — the story resumes once every session has been revoked.
|
|
36
|
+
|
|
37
|
+
## Errors
|
|
38
|
+
|
|
39
|
+
| Error | Meaning |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `UserDirectorySignOutUserErrorTypeEnum.Unauthorized` | The access token is missing, invalid, or expired — there is nothing to revoke. |
|
|
42
|
+
| `UserDirectorySignOutUserErrorTypeEnum.LimitExceeded` | Too many attempts; back off and retry later. |
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
Access tokens already minted remain valid until they expire — they're stateless JWTs and can't be individually invalidated. Keep `accessTokenValidityMinutes` (see [defineUserDirectory](../../../config/core/user-directory.md#parameters)) short to shrink that window.
|
|
47
|
+
|
|
48
|
+
## Related
|
|
49
|
+
|
|
50
|
+
- [askUserDirectoryRevokeRefreshToken](./ask-user-directory-revoke-refresh-token.md) — revokes only a single session, leaving the user's other devices signed in.
|
|
51
|
+
- [askUserDirectoryReadAccessToken](./ask-user-directory-read-access-token.md) / [askUserDirectoryDecodeAccessToken](./ask-user-directory-decode-access-token.md) — inspect an access token.
|
|
52
|
+
- [defineUserDirectory](../../../config/core/user-directory.md) — declares the directory.
|
|
@@ -15,22 +15,24 @@ A **purely declarative** way to apply an event-doc event: the story yields an `e
|
|
|
15
15
|
import { askApplyEventDocEvent } from 'quidproquo-features';
|
|
16
16
|
|
|
17
17
|
export function* askTenantSetBrand(data: TenantSetBrandData) {
|
|
18
|
-
yield* askApplyEventDocEvent(TenantEffect.setBrand, data);
|
|
18
|
+
yield* askApplyEventDocEvent<SetBrandEffect>(TenantEffect.setBrand, data);
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Signature
|
|
23
23
|
|
|
24
24
|
```typescript
|
|
25
|
-
function* askApplyEventDocEvent(eventType:
|
|
25
|
+
function* askApplyEventDocEvent<E extends Effect<string, any>>(eventType: E['type'], data: E['payload']): AskResponse<void>;
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Typed like [askStateDispatchEffect](../../core/state/ask-state-dispatch.md#effect-and-askstatedispatcheffect): an event-doc event IS a special kind of effect (`Effect<type, data>`), so an action creator passes its effect type as `E` and gets `data` checked against it.
|
|
29
|
+
|
|
28
30
|
## Parameters
|
|
29
31
|
|
|
30
32
|
| Parameter | Type | Description |
|
|
31
33
|
| --- | --- | --- |
|
|
32
|
-
| `eventType` | `
|
|
33
|
-
| `data` | `
|
|
34
|
+
| `eventType` | `E['type']` | The effect/event type discriminant, matched by the reducer that folds the document and by the registered processor. |
|
|
35
|
+
| `data` | `E['payload']` | The typed domain data for the event, checked against `E`, opaque to this action itself. |
|
|
34
36
|
|
|
35
37
|
## Returns
|
|
36
38
|
|
package/template/docusaurus/docs/actions/features/event-doc/ask-apply-transient-event-doc-event.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: askApplyTransientEventDocEvent
|
|
3
|
+
description: Declaratively apply an event-doc event into a never-saved transient group, dropped wholesale by key instead of persisted.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# askApplyTransientEventDocEvent
|
|
7
|
+
|
|
8
|
+
The never-saved sibling of [askApplyEventDocEvent](./ask-apply-event-doc-event.md): the story yields an `eventType` and its `data` under a `transientKey`, and the event lands in a transient group that is never persisted, only dropped wholesale by that key. It is the event-sourced way to represent observations that must not survive past their source, such as websocket progress messages, where dropping the key (usually a connection id) reverts the folded view reactively instead of leaving a stuck UI state behind.
|
|
9
|
+
|
|
10
|
+
- **Action type:** `EventDocActionType.ApplyTransientEvent`
|
|
11
|
+
- Transient applies are client-runtime-only by definition: a server "authors" them only via messages a client story processes, never a backend `ApplyTransientEvent` processor. An unbound or backend apply fails loudly, the same as an unbound `ApplyEvent`.
|
|
12
|
+
- The version stamped on the resulting event is not passed by the caller — the binding that applies it stamps its own configured schema version, the same as `askApplyEventDocEvent`.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { askApplyTransientEventDocEvent } from 'quidproquo-features';
|
|
16
|
+
|
|
17
|
+
export function* askZipReportProgress(connectionId: string, data: ZipProgressData) {
|
|
18
|
+
yield* askApplyTransientEventDocEvent<ZipProgressEffect>(connectionId, ZipEffect.Progress, data);
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Signature
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
function* askApplyTransientEventDocEvent<E extends Effect<string, any>>(
|
|
26
|
+
transientKey: string,
|
|
27
|
+
eventType: E['type'],
|
|
28
|
+
data: E['payload'],
|
|
29
|
+
): AskResponse<void>;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Typed like [askApplyEventDocEvent](./ask-apply-event-doc-event.md): an event-doc event IS a special kind of effect (`Effect<type, data>`), so an action creator passes its effect type as `E` and gets `data` checked against it.
|
|
33
|
+
|
|
34
|
+
## Parameters
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Description |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `transientKey` | `string` | Names the drop unit for this event, usually a websocket connection id. Dropping a key clears every event committed under it. |
|
|
39
|
+
| `eventType` | `E['type']` | The effect/event type discriminant, matched by the reducer that folds the document. |
|
|
40
|
+
| `data` | `E['payload']` | The typed domain data for the event, checked against `E`, opaque to this action itself. |
|
|
41
|
+
|
|
42
|
+
## Returns
|
|
43
|
+
|
|
44
|
+
`AskResponse<void>` — applying a transient event never returns a value; a binding that fails to apply it surfaces the failure as its own runtime's error state rather than throwing back through this call.
|
|
45
|
+
|
|
46
|
+
## Notes
|
|
47
|
+
|
|
48
|
+
- This is the contract only — it carries no default behavior on its own. In the event-doc workspace, the binding registered for a slot routes `ApplyTransientEvent` into that slot's transient group under `transientKey`; outside a workspace bind, calling it fails loudly.
|
|
49
|
+
- Transient events never influence the persistable log or validation: they are excluded from the log every save and validator reads, and are dropped wholesale by `transientKey` rather than removed one at a time.
|
|
50
|
+
|
|
51
|
+
## Related
|
|
52
|
+
|
|
53
|
+
- [askApplyEventDocEvent](./ask-apply-event-doc-event.md) — the persisted sibling this action mirrors.
|
|
@@ -14,7 +14,7 @@ These three helpers cover the two flows: presigned URLs for a browser to upload/
|
|
|
14
14
|
|
|
15
15
|
## askEventDocGenerateAssetUploadUrl
|
|
16
16
|
|
|
17
|
-
Mints a guid and a short-lived presigned `PUT` URL the client uploads the bytes to. Returns the guid (as `assetId`) so the caller can record it in a domain event. This is one complete storage operation — no bytes pass through your service.
|
|
17
|
+
Mints a guid and a short-lived presigned `PUT` URL the client uploads the bytes to. Returns the guid (as `assetId`) so the caller can record it in a domain event. This is one complete storage operation — no bytes pass through your service. An optional `contentDisposition` is baked into the upload (the client must `PUT` the matching header) so the stored object serves with it, e.g. `inline` so a rendered PDF previews in an `<iframe>` instead of downloading.
|
|
18
18
|
|
|
19
19
|
```typescript
|
|
20
20
|
import { askEventDocGenerateAssetUploadUrl } from 'quidproquo-features';
|
|
@@ -32,6 +32,7 @@ export function* startImageUpload(docId: string) {
|
|
|
32
32
|
function* askEventDocGenerateAssetUploadUrl(
|
|
33
33
|
docId: string,
|
|
34
34
|
contentType: string,
|
|
35
|
+
contentDisposition?: string,
|
|
35
36
|
): AskResponse<EventDocAssetUploadUrl>;
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -41,6 +42,7 @@ function* askEventDocGenerateAssetUploadUrl(
|
|
|
41
42
|
| --- | --- | --- |
|
|
42
43
|
| `docId` | `string` | The document the asset belongs to — determines the `<docId>/assets/<guid>` blob key. |
|
|
43
44
|
| `contentType` | `string` | The MIME type the client will upload; pinned into the presigned `PUT` URL. |
|
|
45
|
+
| `contentDisposition` | `string` | Optional. Pinned into the presigned `PUT` URL so the stored asset serves with it, e.g. `inline` to preview instead of force-download. |
|
|
44
46
|
|
|
45
47
|
### Returns
|
|
46
48
|
|
package/template/docusaurus/docs/actions/webserver/service-function/ask-service-function-execute.md
CHANGED
|
@@ -13,14 +13,20 @@ Invokes a [service function](../../../config/webserver/service-function.md) —
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { askServiceFunctionExecute } from 'quidproquo-webserver';
|
|
15
15
|
|
|
16
|
-
interface ResizeRequest {
|
|
17
|
-
|
|
16
|
+
interface ResizeRequest {
|
|
17
|
+
drive: string;
|
|
18
|
+
filepath: string;
|
|
19
|
+
width: number;
|
|
20
|
+
}
|
|
21
|
+
interface ResizeResult {
|
|
22
|
+
thumbnailPath: string;
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
export function* askMakeThumbnail(filepath: string) {
|
|
20
26
|
const result = yield* askServiceFunctionExecute<ResizeResult, ResizeRequest>(
|
|
21
|
-
'media',
|
|
22
|
-
'resizeImage',
|
|
23
|
-
{ drive: 'uploads', filepath, width: 256 }
|
|
27
|
+
'media', // service name
|
|
28
|
+
'resizeImage', // function name
|
|
29
|
+
{ drive: 'uploads', filepath, width: 256 }
|
|
24
30
|
);
|
|
25
31
|
|
|
26
32
|
return result.thumbnailPath;
|
|
@@ -34,18 +40,18 @@ function* askServiceFunctionExecute<R, T>(
|
|
|
34
40
|
service: string,
|
|
35
41
|
functionName: string,
|
|
36
42
|
payload: T,
|
|
37
|
-
isAsync?: boolean
|
|
43
|
+
isAsync?: boolean
|
|
38
44
|
): AskResponse<R>;
|
|
39
45
|
```
|
|
40
46
|
|
|
41
47
|
## Parameters
|
|
42
48
|
|
|
43
|
-
| Parameter
|
|
44
|
-
|
|
|
45
|
-
| `service`
|
|
46
|
-
| `functionName` | `string`
|
|
47
|
-
| `payload`
|
|
48
|
-
| `isAsync`
|
|
49
|
+
| Parameter | Type | Default | Description |
|
|
50
|
+
| -------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `service` | `string` | – | Name of the service that owns the target function. |
|
|
52
|
+
| `functionName` | `string` | – | Name of the [service function](../../../config/webserver/service-function.md) to invoke (its `functionName`, which defaults to the story name). |
|
|
53
|
+
| `payload` | `T` | – | The typed payload passed to the function's story. |
|
|
54
|
+
| `isAsync` | `boolean` | `false` | When `false`, wait for and return the result. When `true`, invoke fire-and-forget and resolve without a result. |
|
|
49
55
|
|
|
50
56
|
## Returns
|
|
51
57
|
|
|
@@ -54,5 +60,5 @@ function* askServiceFunctionExecute<R, T>(
|
|
|
54
60
|
## Related
|
|
55
61
|
|
|
56
62
|
- [defineServiceFunction](../../../config/webserver/service-function.md) — declares the function this action invokes.
|
|
57
|
-
- [askServiceRequest](
|
|
63
|
+
- [askServiceRequest](../../features/web-socket-queue/ask-service-request.md) — a related request to another service, dispatched by method name.
|
|
58
64
|
- [askCatch](../../core/system/ask-catch.md) — catch errors thrown by the invoked function.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: defineBackendBundleOptions / defineFrontendBundleOptions
|
|
3
|
-
description: Tune how the service's backend and frontend bundles are built: externals, ignored modules, suppressed warnings, and shared frontend singletons.
|
|
3
|
+
description: "Tune how the service's backend and frontend bundles are built: externals, ignored modules, suppressed warnings, and shared frontend singletons."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# defineBackendBundleOptions / defineFrontendBundleOptions
|
|
@@ -43,6 +43,7 @@ The name of the directory. This is the name you pass as the `userDirectoryName`
|
|
|
43
43
|
| `dnsRecord` | `AuthDirectoryDnsRecord` | – | Serves the pool's hosted UI / OAuth endpoints from a custom domain. See [DNS record](#dns-record). |
|
|
44
44
|
| `customAuthRuntime` | `CustomAuthRuntime` | – | Enables Cognito's `CUSTOM_AUTH` flow, wiring story functions to the define/create/verify auth-challenge Lambda triggers. See [Custom auth runtime](#custom-auth-runtime). |
|
|
45
45
|
| `mfa` | `UserDirectoryMfaSettings` | `{ mode: off, secondFactors: [totp] }` | Multi-factor authentication configuration. See [MFA](#mfa). |
|
|
46
|
+
| `accessTokenValidityMinutes` | `number` | `60` (Cognito default) | Access/ID token (JWT) lifetime in minutes (Cognito allows 5–1440). Shorter shrinks the window in which a revoked session's still-valid access token keeps working, since access tokens are stateless and can't be revoked before they expire — see [askUserDirectoryRevokeRefreshToken](../../actions/core/user-directory/ask-user-directory-revoke-refresh-token.md) / [askUserDirectorySignOutUser](../../actions/core/user-directory/ask-user-directory-sign-out-user.md). Keep it comfortably above however long the client waits before refreshing. |
|
|
46
47
|
| `deprecated` | `boolean` | `false` | Inherited from `QPQConfigAdvancedSettings`; marks the setting as deprecated. |
|
|
47
48
|
|
|
48
49
|
## MFA
|
|
@@ -132,7 +133,7 @@ The `QpqInfCoreUserDirectoryConstruct` deploys:
|
|
|
132
133
|
- **Lambda triggers** (only when configured):
|
|
133
134
|
- `CUSTOM_MESSAGE` — when any `emailTemplates` handler is set.
|
|
134
135
|
- `DEFINE_AUTH_CHALLENGE`, and optionally `CREATE_AUTH_CHALLENGE` / `VERIFY_AUTH_CHALLENGE_RESPONSE` — when `customAuthRuntime` is set.
|
|
135
|
-
- **An app client** with a generated secret, `ADMIN_USER_PASSWORD_AUTH` enabled,
|
|
136
|
+
- **An app client** with a generated secret, `ADMIN_USER_PASSWORD_AUTH` enabled, `CUSTOM_AUTH` enabled when `customAuthRuntime` is set, and access/ID token validity set from `accessTokenValidityMinutes` (Cognito's 60-minute default when omitted).
|
|
136
137
|
- **A custom domain + Route 53 alias** — when `dnsRecord` is set (certificate from `us-east-1`).
|
|
137
138
|
|
|
138
139
|
Services that own a directory get IAM for the admin Cognito operations the actions use (create user, initiate/respond to auth, list/get users, update attributes, set password, describe pool/client, etc.). Services that only reference a **foreign** directory (via `owner`) get no Cognito IAM — token validation runs against the pool's public JWKs over HTTPS and needs none.
|
|
@@ -183,6 +184,7 @@ export default [
|
|
|
183
184
|
- **Passwords:** [askUserDirectoryChangePassword](../../actions/core/user-directory/ask-user-directory-change-password.md), [askUserDirectoryForgotPassword](../../actions/core/user-directory/ask-user-directory-forgot-password.md), [askUserDirectorySetPassword](../../actions/core/user-directory/ask-user-directory-set-password.md).
|
|
184
185
|
- **MFA:** [askUserDirectoryAssociateSoftwareToken](../../actions/core/user-directory/ask-user-directory-associate-software-token.md).
|
|
185
186
|
- **Tokens:** [askUserDirectoryReadAccessToken](../../actions/core/user-directory/ask-user-directory-read-access-token.md), [askUserDirectoryDecodeAccessToken](../../actions/core/user-directory/ask-user-directory-decode-access-token.md).
|
|
187
|
+
- **Signing out:** [askUserDirectoryRevokeRefreshToken](../../actions/core/user-directory/ask-user-directory-revoke-refresh-token.md) (single session), [askUserDirectorySignOutUser](../../actions/core/user-directory/ask-user-directory-sign-out-user.md) (every session).
|
|
186
188
|
- **All User Directory actions:** see the [User Directory actions](../../actions/core/user-directory/ask-user-directory-authenticate-user.md) group.
|
|
187
189
|
- **Turn-key auth:** [defineAuthSystem](../webserver/auth-system.md) (quidproquo-webserver) creates a user directory and the login / refresh / password-recovery HTTP routes in one call.
|
|
188
190
|
- **AWS tuning:** [defineAwsDataStoreRemovalPolicy](../config-aws/aws-data-store-removal-policy.md) — retain vs destroy the Cognito user pool on teardown.
|
|
@@ -62,6 +62,7 @@ The single `options` argument is an `EventDocRoutesOptions`:
|
|
|
62
62
|
| `eventRenderer` | `string` | – | Name of a registered inline function. When set, a `GET {basePath}/{id}/render` route is mounted; it invokes the renderer with the document's full `{ events }` log, which folds + renders to HTML. |
|
|
63
63
|
| `onPublish` | `string` | – | Name of a registered inline function. When set, every successful append of a Publish event invokes it with `{ docId, event, summary }`, after the event is durably written and the summary re-derived. This is the seam for syncing a folded document into a materialized read model. Errors propagate to the caller: the event has landed but the side effect did not, so the caller learns the read model may be stale. |
|
|
64
64
|
| `scopeResolver` | `string` | – | Name of a registered inline function. When set, every route invokes it with `{ event }` before running; a non-null result becomes the ambient storage scope for the whole request, transparently partitioning the collection's stores and assets (e.g. per-tenant). Null means unscoped. Omit for collections that never partition. |
|
|
65
|
+
| `excludeRoutes` | `EventDocRouteName[]` | `[]` | Route names to leave unmounted (`'list' \| 'get' \| 'listEvents' \| 'render' \| 'create' \| 'appendEvent' \| 'createAsset' \| 'getAsset' \| 'remove'`). For a collection that must own one of these itself instead of using the stock behavior — e.g. a `create` that must also perform some side effect the stock controller doesn't know about. |
|
|
65
66
|
|
|
66
67
|
### `RouteAuthSettings`
|
|
67
68
|
|
|
@@ -12,8 +12,8 @@ Wires up **everything for org/tenant support**, declared identically in every se
|
|
|
12
12
|
- Everything else, gated to the owner's deploy only via [defineServiceSettings](../core/service-settings.md):
|
|
13
13
|
- The tenant stores ([defineTenantStores](./tenant-stores.md)): the tenant event-doc collection plus the materialized record table.
|
|
14
14
|
- The publish-to-record-store sync: an inline function (`askTenantOnPublish`) that runs on every published tenant document, re-folds the full event log, and upserts the resulting `TenantRecord`. It is a plain upsert of the fold result, so publish retries and repair re-runs are safe.
|
|
15
|
-
- The
|
|
16
|
-
- The
|
|
15
|
+
- The stock event-doc CRUD at `{basePath}` ([defineEventDocRoutes](./event-doc-routes.md) for the `tenants` store, `tenant` type, with the publish sync wired in as `onPublish`, and `create` excluded — see below).
|
|
16
|
+
- The membership-gated routes at `{myTenantsBasePath}`: list my tenants, create, get record, and get logo.
|
|
17
17
|
|
|
18
18
|
- **On AWS:** on the owner's deploy, this deploys everything [defineTenantStores](./tenant-stores.md) deploys (two DynamoDB tables and an S3 bucket) plus the API Gateway routes and Lambda handlers from [defineEventDocRoutes](./event-doc-routes.md) and the four tenant routes below. On every other service's deploy, only the `userTenantLinks` reference resolves (no new table); the inline functions deploy no infrastructure of their own anywhere.
|
|
19
19
|
|
|
@@ -26,6 +26,7 @@ export default [
|
|
|
26
26
|
...defineTenant({
|
|
27
27
|
owner: { module: 'ca' },
|
|
28
28
|
basePath: '/tenants',
|
|
29
|
+
myTenantsBasePath: '/my-tenants',
|
|
29
30
|
routeAuthSettings: { userDirectoryName: 'users' },
|
|
30
31
|
}),
|
|
31
32
|
];
|
|
@@ -37,12 +38,12 @@ All paths are prefixed with the version segment `/v{version}` (default `/v1`) an
|
|
|
37
38
|
|
|
38
39
|
| Method | Path | Purpose |
|
|
39
40
|
| --- | --- | --- |
|
|
40
|
-
| `GET` | `{
|
|
41
|
-
| `POST` | `{
|
|
42
|
-
| `GET` | `{
|
|
43
|
-
| `GET` | `{
|
|
41
|
+
| `GET` | `{myTenantsBasePath}` | The authenticated user's tenants, as `EventDocSummary` rows. Runs under the request's scope: memberships homed in the caller's current partition hydrate live (drafts included); the rest hydrate from the published `TenantRecord` registry. |
|
|
42
|
+
| `POST` | `{myTenantsBasePath}` | Create a tenant (body `{ name }`); the caller becomes its first member. Runs under the request's scope, so the new tenant doc lands in the caller's current partition. This is the only way to create a tenant — the stock `create` route is excluded at `{basePath}` so a new tenant is never made without also linking its creator. |
|
|
43
|
+
| `GET` | `{myTenantsBasePath}/{id}` | One tenant's materialized record (the fast path). Members only: non-members get `Forbidden`, a missing record gets `NotFound`. |
|
|
44
|
+
| `GET` | `{myTenantsBasePath}/{id}/logo` | A presigned, short-lived URL for the tenant's logo blob. Members only: non-members get `Forbidden`; a missing record or a tenant with no logo gets `NotFound`. Presigned in the scope the tenant doc was published under (recorded on the `TenantRecord`), not the reader's own scope, since the logo asset lives in the doc's home partition. |
|
|
44
45
|
|
|
45
|
-
On top of these, the
|
|
46
|
+
On top of these, the stock event-doc route set (get, append, list events, assets, remove, and so on — everything but `create`) is mounted at `{basePath}`, named after the model type like any other collection; see [defineEventDocRoutes](./event-doc-routes.md#routes-mounted) for the list. `{basePath}` and `{myTenantsBasePath}` must be distinct and neither may be a path segment under the other: `{basePath}/{id}` matches any single segment, so a literal sibling path would be ambiguous with a tenant whose id happens to match it.
|
|
46
47
|
|
|
47
48
|
## Signature
|
|
48
49
|
|
|
@@ -57,7 +58,8 @@ The single `options` argument is a `TenantOptions` (a `TenantRoutesOptions` plus
|
|
|
57
58
|
| Property | Type | Default | Description |
|
|
58
59
|
| --- | --- | --- | --- |
|
|
59
60
|
| `owner` | `CrossModuleOwner & { module: string }` | – (required) | The service that owns the tenant registry, e.g. `{ module: 'ca' }`. Pass the **same** value in every service's `defineTenant` call; the registry (stores, publish sync, management routes) only materializes when the deploying module matches this. |
|
|
60
|
-
| `basePath` | `` `/${string}` `` | – (required) |
|
|
61
|
+
| `basePath` | `` `/${string}` `` | – (required) | The tenant collection root, e.g. `/tenants`. The stock event-doc CRUD (minus `create`) mounts here. Only used on the owner's deploy, but required on every call for type consistency across services. |
|
|
62
|
+
| `myTenantsBasePath` | `` `/${string}` `` | – (required) | URL prefix the membership-gated routes mount under, e.g. `/my-tenants` (list mine, create, get record, get logo). Must not be a child path of `basePath` — see [Routes mounted](#routes-mounted). Only used on the owner's deploy, but required on every call for type consistency across services. |
|
|
61
63
|
| `routeAuthSettings` | `RouteAuthSettings` | – (required) | Auth applied to every mounted route (see [route](../webserver/route.md)). Required here, unlike the generic event-doc routes: tenant routes are meaningless unauthenticated, since membership keys off the user. Only used on the owner's deploy. |
|
|
62
64
|
| `version` | `number` | `1` | Version number for the `/v{version}` path prefix on every route. Only used on the owner's deploy. |
|
|
63
65
|
| `tenantHeaderName` | `string` | `'x-qpq-tenant-id'` | The header the client sends its selected tenant id on. Exposed to the tenant routes as the `tenantHeaderName` global, which the scope resolver reads. |
|
|
@@ -65,7 +67,7 @@ The single `options` argument is a `TenantOptions` (a `TenantRoutesOptions` plus
|
|
|
65
67
|
## Notes
|
|
66
68
|
|
|
67
69
|
- Tenant state is event-sourced: the `tenants` event-doc collection is the audit-trailed source of truth, and the `tenantRecords` table is a read model synced on publish. Request handlers never write the record table directly.
|
|
68
|
-
- The `tenants` collection is scope-resolved like any other tenanted collection (see [defineTenantedEventDoc](./tenanted-event-doc.md)): a doc is only visible/editable from the scope that owns it, including through the
|
|
70
|
+
- The `tenants` collection is scope-resolved like any other tenanted collection (see [defineTenantedEventDoc](./tenanted-event-doc.md)): a doc is only visible/editable from the scope that owns it, including through the stock CRUD at `{basePath}`. There is no cross-scope doc read — listing memberships homed in another scope goes through the published `TenantRecord`, not the doc store.
|
|
69
71
|
- The `TenantRecord` produced by the publish sync carries `tenantId`, `name`, `brandColors`, `logo` (an asset ref, resolved to a URL via the get-logo route above), `scope` (the storage scope the doc was published under, used to presign the logo for cross-scope readers), `createdAt`, `updatedAt`, `createdByUserId`, and a `status` derived from the summary (`deleted` when the summary has a `deletedAt`, otherwise `active`).
|
|
70
72
|
- `defineTenant` registers the scope resolver but does not apply it to anything. To tenant-scope one of your own collections, pass `TENANT_SCOPE_RESOLVER_FN` as that collection's `scopeResolver` option (or use [defineTenantedEventDoc](./tenanted-event-doc.md), which does this for you).
|
|
71
73
|
- The scope resolver always resolves to a typed scope — a membership-checked `TENANT#<id>` for a request that names a tenant, or the caller's own `PERSONAL#<userId>` when it doesn't. A tenant-scoped collection or connection is never left unscoped.
|
|
@@ -75,7 +77,7 @@ The single `options` argument is a `TenantOptions` (a `TenantRoutesOptions` plus
|
|
|
75
77
|
|
|
76
78
|
- [defineTenantStores](./tenant-stores.md): the store half of this helper (owner-only).
|
|
77
79
|
- [defineTenantedEventDoc](./tenanted-event-doc.md): a `defineEventDoc` with `TENANT_SCOPE_RESOLVER_FN` pre-wired as `scopeResolver`.
|
|
78
|
-
- [defineEventDocRoutes](./event-doc-routes.md): the
|
|
80
|
+
- [defineEventDocRoutes](./event-doc-routes.md): the stock CRUD mounted at `{basePath}`, and home of the `scopeResolver` / `onPublish` / `excludeRoutes` options.
|
|
79
81
|
- [defineWebSocketQueue](./web-socket-queue.md): where the tenant connection-scope resolver plugs in.
|
|
80
82
|
- [defineTenantedWebSocketQueue](./tenanted-web-socket-queue.md): a `defineWebSocketQueue` with that resolver pre-wired.
|
|
81
83
|
- [defineServiceSettings](../core/service-settings.md): the per-module gating mechanism this uses to materialize the registry only on the owner.
|
|
@@ -80,7 +80,7 @@ export interface QPQConfigAdvancedWebsocketQueueSettings extends QPQConfigAdvanc
|
|
|
80
80
|
|
|
81
81
|
## Returns
|
|
82
82
|
|
|
83
|
-
A `QPQConfig` array containing: three globals, the connection [key-value store](../core/key-value-store.md), and the [WebSocket API](
|
|
83
|
+
A `QPQConfig` array containing: three globals, the connection [key-value store](../core/key-value-store.md), and the [WebSocket API](../webserver/websocket.md) with the queue's built-in handlers.
|
|
84
84
|
|
|
85
85
|
## Related
|
|
86
86
|
|
|
@@ -57,6 +57,7 @@ The name of the [user directory](../../config/core/user-directory.md) to create
|
|
|
57
57
|
| `mfa` | `UserDirectoryMfaSettings` | – | Forwarded to the user directory: multi-factor authentication configuration. See [MFA](../../config/core/user-directory.md#mfa). |
|
|
58
58
|
| `basePath` | `string` | `''` | A path prefix applied to every auth route. E.g. `'/auth'` produces `/auth/login`, `/auth/refreshToken`, and so on. |
|
|
59
59
|
| `allowedOrigins` | `string[]` | – | Allowed browser origins (CORS) applied to every generated auth route. |
|
|
60
|
+
| `accessTokenValidityMinutes` | `number` | `60` (Cognito default) | Forwarded to the user directory: access/ID token (JWT) lifetime in minutes (Cognito allows 5–1440). Shorter shrinks the window in which a revoked session's access token keeps working. Keep it comfortably above however long the client waits before refreshing (the stock web client refreshes ~10 minutes before expiry). See [defineUserDirectory](../../config/core/user-directory.md#parameters). |
|
|
60
61
|
|
|
61
62
|
## Generated routes
|
|
62
63
|
|
|
@@ -66,6 +67,8 @@ With `basePath` = `''` (the default), `defineAuthSystem` registers these `POST`
|
|
|
66
67
|
| --- | --- | --- |
|
|
67
68
|
| `/login` | `login` | No |
|
|
68
69
|
| `/refreshToken` | `refreshToken` | No |
|
|
70
|
+
| `/logout` | `logout` | No |
|
|
71
|
+
| `/logoutEverywhere` | `logoutEverywhere` | No |
|
|
69
72
|
| `/challenge` | `respondToAuthChallenge` | No |
|
|
70
73
|
| `/associateSoftwareToken` | `associateSoftwareToken` | No |
|
|
71
74
|
| `/forgotPassword` | `forgotPassword` | No |
|
|
@@ -74,6 +77,11 @@ With `basePath` = `''` (the default), `defineAuthSystem` registers these `POST`
|
|
|
74
77
|
|
|
75
78
|
Each path is prefixed with `basePath` when provided (e.g. `/auth/login`). The handler stories correspond to the [User Directory actions](../../actions/core/user-directory/ask-user-directory-authenticate-user.md) — for example `login` authenticates a user, `refreshToken` refreshes an access token, and `respondToAuthChallenge` completes a challenge/MFA flow.
|
|
76
79
|
|
|
80
|
+
`/logout` and `/logoutEverywhere` are unauthenticated routes that are each self-authorizing by the token they carry, and both are best-effort — they always respond `{ success: true }` even if the token was already invalid, since a client's local logout must never fail:
|
|
81
|
+
|
|
82
|
+
- `/logout` takes `{ refreshToken }` in the body and revokes just that one session ([askUserDirectoryRevokeRefreshToken](../../actions/core/user-directory/ask-user-directory-revoke-refresh-token.md)) — "log out this device."
|
|
83
|
+
- `/logoutEverywhere` reads the access token from the request headers and revokes every session for that user ([askUserDirectorySignOutUser](../../actions/core/user-directory/ask-user-directory-sign-out-user.md)) — "sign out of all devices."
|
|
84
|
+
|
|
77
85
|
## How auth ties routes to the user directory
|
|
78
86
|
|
|
79
87
|
A route becomes authenticated by setting `routeAuthSettings.userDirectoryName` (which `defineAuthSystem` does for `/changePassword`). At runtime the webserver validates the caller's access token against that directory before invoking the handler — decoding the token, checking it is valid, and making its claims/roles available. Any route in the application (not just the ones generated here) can opt into the same protection by referencing this directory's name, because the directory is declared ungated at the top level.
|
|
@@ -106,5 +114,5 @@ export default [
|
|
|
106
114
|
|
|
107
115
|
- [defineUserDirectory](../../config/core/user-directory.md) — the user directory `defineAuthSystem` creates; documents the directory options that `phoneRequired`, `selfSignUpEnabled`, `emailTemplates`, and `mfa` forward to, plus the AWS Cognito infrastructure.
|
|
108
116
|
- [defineApiKey](./api-key.md) — the complementary auth mechanism (shared-secret / machine callers) for routes.
|
|
109
|
-
- **User Directory actions** — the auth operations the generated handlers wrap: [askUserDirectoryAuthenticateUser](../../actions/core/user-directory/ask-user-directory-authenticate-user.md), [askUserDirectoryRefreshToken](../../actions/core/user-directory/ask-user-directory-refresh-token.md), [askUserDirectoryRespondToAuthChallenge](../../actions/core/user-directory/ask-user-directory-respond-to-auth-challenge.md), [askUserDirectoryChangePassword](../../actions/core/user-directory/ask-user-directory-change-password.md), [askUserDirectoryForgotPassword](../../actions/core/user-directory/ask-user-directory-forgot-password.md), [askUserDirectoryConfirmForgotPassword](../../actions/core/user-directory/ask-user-directory-confirm-forgot-password.md), [askUserDirectoryAssociateSoftwareToken](../../actions/core/user-directory/ask-user-directory-associate-software-token.md).
|
|
117
|
+
- **User Directory actions** — the auth operations the generated handlers wrap: [askUserDirectoryAuthenticateUser](../../actions/core/user-directory/ask-user-directory-authenticate-user.md), [askUserDirectoryRefreshToken](../../actions/core/user-directory/ask-user-directory-refresh-token.md), [askUserDirectoryRevokeRefreshToken](../../actions/core/user-directory/ask-user-directory-revoke-refresh-token.md), [askUserDirectorySignOutUser](../../actions/core/user-directory/ask-user-directory-sign-out-user.md), [askUserDirectoryRespondToAuthChallenge](../../actions/core/user-directory/ask-user-directory-respond-to-auth-challenge.md), [askUserDirectoryChangePassword](../../actions/core/user-directory/ask-user-directory-change-password.md), [askUserDirectoryForgotPassword](../../actions/core/user-directory/ask-user-directory-forgot-password.md), [askUserDirectoryConfirmForgotPassword](../../actions/core/user-directory/ask-user-directory-confirm-forgot-password.md), [askUserDirectoryAssociateSoftwareToken](../../actions/core/user-directory/ask-user-directory-associate-software-token.md).
|
|
110
118
|
- **Routes and APIs** — the generated endpoints are `defineRoute` settings; see the route / `defineApi` config settings in quidproquo-webserver to add and protect your own routes with this directory.
|
|
@@ -20,4 +20,38 @@ Platform-specific implementations that execute actions.
|
|
|
20
20
|
|
|
21
21
|
## Runtime
|
|
22
22
|
|
|
23
|
-
The orchestration layer that executes stories by processing yielded actions through the appropriate processors.
|
|
23
|
+
The orchestration layer that executes stories by processing yielded actions through the appropriate processors.
|
|
24
|
+
|
|
25
|
+
## Why every function starts with `ask`
|
|
26
|
+
|
|
27
|
+
Every quidproquo function you call from a story starts with `ask`: `askDateNow`, `askFileReadTextContents`, `askRunParallel`. That prefix isn't decoration, it tells you exactly what kind of function you're holding. Here's the whole of `askDateNow`:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
export function* askDateNow(): DateNowActionRequester {
|
|
31
|
+
return yield { type: DateActionType.Now };
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Notice it never calls `new Date()`. It yields a plain action object and suspends. The runtime catches that action, hands it to the platform's `DateNow` processor (which actually reads the clock), then resumes the generator with the result. The `ask` prefix captures three things about that arrangement:
|
|
36
|
+
|
|
37
|
+
### 1. It's a request, not an execution
|
|
38
|
+
|
|
39
|
+
Calling `askDateNow()` does nothing on its own. It builds a generator that *describes* what it wants, and the runtime decides how to fulfil it. That's what keeps stories pure and platform-agnostic: the "what" lives in your story, the "how" lives in the `quidproquo-actionprocessor-*` packages. Your story doesn't know whether the clock is a Lambda, a Node process, or a test fixture.
|
|
40
|
+
|
|
41
|
+
### 2. It's a question with an answer
|
|
42
|
+
|
|
43
|
+
The return type is literally `AskResponse<T>`. You ask, the runtime responds, and the response arrives as the generator's return value:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
const now = yield* askDateNow(); // now: QpqIsoDateTime
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every action pairs a **requester** (the `ask` function your story calls) with a **processor** (the platform implementation that produces the answer). They're two halves of the same conversation.
|
|
50
|
+
|
|
51
|
+
### 3. It signals the calling convention
|
|
52
|
+
|
|
53
|
+
Anything starting with `ask` must be invoked with `yield*` from inside another generator. The prefix works like a syntax marker, the same way `use` marks a React hook: see `ask`, write `yield*`. Higher-level stories that compose other ask functions follow the same rule and get the same prefix (`askGetCurrentEpochMs`, `askRunParallel`, `askCatch`).
|
|
54
|
+
|
|
55
|
+
The practical payoff is testability and portability. Because `askDateNow` only describes intent, a test can run the same story with a fake processor that returns a fixed timestamp, and the identical story code runs on Lambda, Node, or the browser just by swapping the processor set.
|
|
56
|
+
|
|
57
|
+
Both halves of this contract (always `yield*` an ask call, never name a plain function `ask*`) are enforced at edit time by the [quidproquo ESLint plugin](./eslint-plugin.md), which ships with the shared lint config and auto-fixes missing `yield*`s.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
sidebar_position: 5
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# ESLint Plugin
|
|
6
|
+
|
|
7
|
+
The `ask` prefix is a contract: [every function that starts with `ask` is a story](./core-concepts.md#why-every-function-starts-with-ask), and every story must be run with `yield*`. Contracts are only useful if something enforces them, so quidproquo ships an ESLint plugin that does. It catches the two mistakes everyone makes at least once:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// error: creates a generator that never runs
|
|
11
|
+
const now = askDateNow();
|
|
12
|
+
|
|
13
|
+
// error: hands the generator to the runtime as a value instead of running it
|
|
14
|
+
const now = yield askDateNow();
|
|
15
|
+
|
|
16
|
+
// correct
|
|
17
|
+
const now = yield* askDateNow();
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
If you use the shared config, you already have it. `quidproquo-eslint-config` wires the plugin in, so every `create-qpq-app` project and every package extending that config gets both rules as errors with no extra setup.
|
|
23
|
+
|
|
24
|
+
To use the plugin on its own, without the rest of the shared config:
|
|
25
|
+
|
|
26
|
+
```js title="eslint.config.mjs"
|
|
27
|
+
import { qpqPlugin } from 'quidproquo-eslint-config';
|
|
28
|
+
|
|
29
|
+
export default [
|
|
30
|
+
qpqPlugin.configs.recommended,
|
|
31
|
+
// or pick rules individually:
|
|
32
|
+
// { plugins: { qpq: qpqPlugin }, rules: { 'qpq/require-yield-star': 'error' } },
|
|
33
|
+
];
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## `qpq/require-yield-star`
|
|
37
|
+
|
|
38
|
+
Inside a generator, any call to an `ask*` function (including member calls like `authLogic.askLogin()` and tagged templates like `` askLog`...` ``) must be delegated with `yield*`.
|
|
39
|
+
|
|
40
|
+
The rule is auto-fixable. Run `eslint --fix` and it will:
|
|
41
|
+
|
|
42
|
+
- insert `yield*` in front of a bare call
|
|
43
|
+
- upgrade a plain `yield` to `yield*`
|
|
44
|
+
- replace a wrong `await` with `yield*`
|
|
45
|
+
- add parentheses where a bare yield expression is not allowed, for example `!askUserExists(id)` becomes `!(yield* askUserExists(id))`
|
|
46
|
+
|
|
47
|
+
Two patterns stay legal, because they are how stories are meant to be composed and tested:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// ask calls as arguments build generators for the outer ask to run
|
|
51
|
+
yield* askRunParallel([askRandomNumber(), askDateNow()]);
|
|
52
|
+
|
|
53
|
+
// outside a generator you cannot yield; tests and forwarding
|
|
54
|
+
// wrappers create generators and hand them off
|
|
55
|
+
const result = runStory(askDateNow(), mocks);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `qpq/ask-prefix-generator-only`
|
|
59
|
+
|
|
60
|
+
The reverse direction: `ask` is reserved for stories, the same way `use` is reserved for React hooks. A plain function named `ask*` breaks the contract, because callers will `yield*` something that is not a generator.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// error: not a generator
|
|
64
|
+
function askUserName() {
|
|
65
|
+
return 'joe';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// correct
|
|
69
|
+
function* askUserName(): AskResponse<string> {
|
|
70
|
+
return yield* askConfigGetParameter('user-name');
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The rule checks every kind of function literal: declarations, arrows, object methods, class methods and class properties. Aliases and factory results are fine, since the right-hand side already is (or produces) a story:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// both legal: not function literals
|
|
78
|
+
const askLog = askLogTemplateLiteral;
|
|
79
|
+
const askActiveTenantRead = createContextReader(tenantContext);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
There is no auto-fix for this one. Whether the function should become a generator or lose the prefix is a judgement call.
|
|
83
|
+
|
|
84
|
+
## Why bother
|
|
85
|
+
|
|
86
|
+
TypeScript alone cannot catch a missing `yield*`. Calling `askDateNow()` without yielding is perfectly well-typed, it just returns a generator that nobody ever runs, and the action inside it silently never happens. That failure mode produces no error, no log line, and no clue. A lint rule at edit time is the cheapest place to catch it.
|
|
@@ -96,7 +96,9 @@ export const health = dynamicRoute(
|
|
|
96
96
|
The story never reads the clock itself — it *asks* for the time by yielding
|
|
97
97
|
an action, and the runtime answers. On your machine that's the dev server;
|
|
98
98
|
in production it's whatever platform the app is deployed to. Same story,
|
|
99
|
-
zero changes.
|
|
99
|
+
zero changes. That's why every quidproquo function starts with `ask`: see
|
|
100
|
+
[why every function starts with ask](./core-concepts.md#why-every-function-starts-with-ask)
|
|
101
|
+
for the full story.
|
|
100
102
|
|
|
101
103
|
Add your own route by dropping a file next to `health.ts`, exporting it from
|
|
102
104
|
the controller `index.ts`, and saving — the dev server rebuilds and restarts
|
|
@@ -104,12 +104,56 @@ body {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
.navbar__title {
|
|
107
|
+
position: relative;
|
|
107
108
|
font-family: var(--qpq-font-display);
|
|
108
109
|
font-size: 19px;
|
|
109
110
|
font-weight: 600;
|
|
110
111
|
letter-spacing: 0.02em;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/* Glint overlay: the same glyphs stacked over the title, transparent except
|
|
115
|
+
for a narrow band clipped to the letterforms. It rests off-canvas, then
|
|
116
|
+
makes one eased sweep across per cycle. The band is two-tone (white into
|
|
117
|
+
cyan) so it stays visible whatever the letter color underneath: the cyan
|
|
118
|
+
half carries the shimmer on this all-light title. The "/ ''" marks the
|
|
119
|
+
duplicated text as decorative; browsers without that syntax skip the
|
|
120
|
+
overlay entirely, which just means no shimmer. */
|
|
121
|
+
.navbar__title::after {
|
|
122
|
+
content: 'quidproquo' / '';
|
|
123
|
+
position: absolute;
|
|
124
|
+
inset: 0;
|
|
125
|
+
color: transparent;
|
|
126
|
+
background-image: linear-gradient(
|
|
127
|
+
105deg,
|
|
128
|
+
rgba(255, 255, 255, 0) 38%,
|
|
129
|
+
rgba(255, 255, 255, 0.85) 45%,
|
|
130
|
+
rgba(34, 211, 238, 0.85) 53%,
|
|
131
|
+
rgba(34, 211, 238, 0) 60%
|
|
132
|
+
);
|
|
133
|
+
background-size: 250% 100%;
|
|
134
|
+
background-repeat: no-repeat;
|
|
135
|
+
background-position: 130% 0;
|
|
136
|
+
-webkit-background-clip: text;
|
|
137
|
+
background-clip: text;
|
|
138
|
+
animation: qpq-title-shine 7s ease-in-out infinite;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@keyframes qpq-title-shine {
|
|
142
|
+
0%,
|
|
143
|
+
72% {
|
|
144
|
+
background-position: 130% 0;
|
|
145
|
+
}
|
|
146
|
+
100% {
|
|
147
|
+
background-position: -130% 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (prefers-reduced-motion: reduce) {
|
|
152
|
+
.navbar__title::after {
|
|
153
|
+
animation: none;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
.navbar__logo {
|
|
114
158
|
filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.5));
|
|
115
159
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
-
<rect x="
|
|
3
|
-
<
|
|
2
|
+
<rect x="1" y="1" width="30" height="30" rx="6" fill="#071018"/>
|
|
3
|
+
<g transform="translate(4.5 4.5) scale(0.0494) translate(-24.4 -24.4)">
|
|
4
|
+
<path fill="#22d3ee" d="M271.42,489.64h-10.72c-128.68,0-235.74-102.93-236.29-231.61S128.97,23.86,258.03,24.41c128.68.55,231.6,107.61,231.6,236.29v11.61c0,.09-.07.16-.16.16h-109.69c-.09,0-.16-.07-.16-.16v-12.61c0-65.9-53.82-123.71-119.7-125.24-69.89-1.62-127.08,55.57-125.46,125.46,1.53,65.89,59.34,119.71,125.24,119.71h11.72c.09,0,.16.07.16.16v109.69c0,.09-.07.16-.16.16Z"/>
|
|
5
|
+
<rect fill="#8df6ff" x="268.31" y="273.22" width="106.91" height="106.91"/>
|
|
6
|
+
<rect fill="#8df6ff" x="375.62" y="380.53" width="106.91" height="106.91"/>
|
|
7
|
+
</g>
|
|
4
8
|
</svg>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="
|
|
2
|
-
<
|
|
3
|
-
<
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="24.4 24.4 465.3 465.3">
|
|
2
|
+
<path fill="#22d3ee" d="M271.42,489.64h-10.72c-128.68,0-235.74-102.93-236.29-231.61S128.97,23.86,258.03,24.41c128.68.55,231.6,107.61,231.6,236.29v11.61c0,.09-.07.16-.16.16h-109.69c-.09,0-.16-.07-.16-.16v-12.61c0-65.9-53.82-123.71-119.7-125.24-69.89-1.62-127.08,55.57-125.46,125.46,1.53,65.89,59.34,119.71,125.24,119.71h11.72c.09,0,.16.07.16.16v109.69c0,.09-.07.16-.16.16Z"/>
|
|
3
|
+
<rect fill="#8df6ff" x="268.31" y="273.22" width="106.91" height="106.91"/>
|
|
4
|
+
<rect fill="#8df6ff" x="375.62" y="380.53" width="106.91" height="106.91"/>
|
|
4
5
|
</svg>
|