@forge-cms/runtime 0.5.0 → 0.7.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/dist/auth-handlers.d.ts +43 -1
- package/dist/auth-handlers.d.ts.map +1 -1
- package/dist/auth-handlers.js +64 -18
- package/dist/auth-handlers.js.map +1 -1
- package/dist/auth-managed.d.ts +19 -0
- package/dist/auth-managed.d.ts.map +1 -0
- package/dist/auth-managed.js +24 -0
- package/dist/auth-managed.js.map +1 -0
- package/dist/body.d.ts +28 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/body.js +81 -0
- package/dist/body.js.map +1 -0
- package/dist/concurrency.d.ts +8 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/errors.d.ts +46 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +61 -0
- package/dist/errors.js.map +1 -1
- package/dist/files.d.ts +15 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +55 -14
- package/dist/files.js.map +1 -1
- package/dist/globals.d.ts +21 -3
- package/dist/globals.d.ts.map +1 -1
- package/dist/globals.js +216 -28
- package/dist/globals.js.map +1 -1
- package/dist/handlers.d.ts +3 -2
- package/dist/handlers.d.ts.map +1 -1
- package/dist/handlers.js +51 -49
- package/dist/handlers.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/localization.d.ts +17 -0
- package/dist/localization.d.ts.map +1 -1
- package/dist/localization.js +56 -0
- package/dist/localization.js.map +1 -1
- package/dist/operations.d.ts +51 -12
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +728 -125
- package/dist/operations.js.map +1 -1
- package/dist/relation-integrity.d.ts +31 -13
- package/dist/relation-integrity.d.ts.map +1 -1
- package/dist/relation-integrity.js +62 -6
- package/dist/relation-integrity.js.map +1 -1
- package/dist/relation-lifecycle.d.ts +95 -0
- package/dist/relation-lifecycle.d.ts.map +1 -0
- package/dist/relation-lifecycle.js +424 -0
- package/dist/relation-lifecycle.js.map +1 -0
- package/dist/runtime.d.ts +21 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +112 -20
- package/dist/runtime.js.map +1 -1
- package/dist/storage-intents.d.ts +96 -0
- package/dist/storage-intents.d.ts.map +1 -0
- package/dist/storage-intents.js +217 -0
- package/dist/storage-intents.js.map +1 -0
- package/dist/system-fields.d.ts +27 -0
- package/dist/system-fields.d.ts.map +1 -0
- package/dist/system-fields.js +74 -0
- package/dist/system-fields.js.map +1 -0
- package/dist/versions.d.ts +73 -1
- package/dist/versions.d.ts.map +1 -1
- package/dist/versions.js +204 -17
- package/dist/versions.js.map +1 -1
- package/package.json +9 -7
package/dist/errors.js
CHANGED
|
@@ -73,6 +73,67 @@ export class UniqueConstraintError extends ForgeError {
|
|
|
73
73
|
this.fields = fields;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A generic `create`/`update`/`delete` targeted a collection whose lifecycle the configured
|
|
78
|
+
* `AuthAdapter` owns (`AuthAdapter.managesCollection`, spec 061). Those mutations would bypass the
|
|
79
|
+
* adapter's invariants — first-admin provisioning, last-admin protection, password hashing, email
|
|
80
|
+
* normalisation, session versioning — so they are refused for every caller, trusted (`overrideAccess:
|
|
81
|
+
* true`) or not. Deliberately says nothing about which adapter or how; the way out is the auth
|
|
82
|
+
* adapter's own user-management operations.
|
|
83
|
+
*/
|
|
84
|
+
export class AuthManagedCollectionError extends ForgeError {
|
|
85
|
+
collection;
|
|
86
|
+
constructor(collection) {
|
|
87
|
+
super(`Collection '${collection}' is managed by the configured auth adapter and cannot be mutated ` +
|
|
88
|
+
`through generic collection CRUD. Use the auth adapter's user-management operations instead.`, 403, 'AUTH_MANAGED_COLLECTION');
|
|
89
|
+
this.collection = collection;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A versioned document mutation lost a race (spec 062): another write committed a new version of the
|
|
94
|
+
* same document after this one read it, so this one's document write and snapshot were rolled back
|
|
95
|
+
* together — nothing was written. Forge never retries it automatically (`before*` hooks may already
|
|
96
|
+
* have run); re-read the document and submit again. Deliberately names the public collection slug,
|
|
97
|
+
* never the internal `_versions_*` table whose unique index detected the conflict.
|
|
98
|
+
*
|
|
99
|
+
* Spec 064 reuses it for relation lifecycle conflicts — a referenced document deleted while a write was
|
|
100
|
+
* in progress, or a relation delete whose graph changed before it committed — with a `message` saying
|
|
101
|
+
* so. The meaning is the same: another request changed what this one depended on; nothing was written.
|
|
102
|
+
*/
|
|
103
|
+
export class ConcurrentModificationError extends ForgeError {
|
|
104
|
+
collection;
|
|
105
|
+
id;
|
|
106
|
+
constructor(collection, id, message) {
|
|
107
|
+
super(message ??
|
|
108
|
+
`Document '${id}' in '${collection}' was changed by another request while this one was in ` +
|
|
109
|
+
`progress; nothing was written. Reload it and try again.`, 409, 'CONCURRENT_MODIFICATION');
|
|
110
|
+
this.collection = collection;
|
|
111
|
+
this.id = id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A request body over a Forge-owned size bound (spec 069; today: the auth JSON bodies read by
|
|
116
|
+
* `readBoundedJsonObject`). Says nothing about the body's contents.
|
|
117
|
+
*/
|
|
118
|
+
export class PayloadTooLargeError extends ForgeError {
|
|
119
|
+
constructor(message = 'Request body is too large') {
|
|
120
|
+
super(message, 413, 'PAYLOAD_TOO_LARGE');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The host's auth throttle refused a login/signup attempt (spec 069). The message is fixed and generic:
|
|
125
|
+
* it never says whether the account exists, which limit fired or how many attempts remain.
|
|
126
|
+
* `retryAfterSeconds` is already validated (a positive integer) when present; the auth handlers emit it
|
|
127
|
+
* as `Retry-After`.
|
|
128
|
+
*/
|
|
129
|
+
export class RateLimitedError extends ForgeError {
|
|
130
|
+
retryAfterSeconds;
|
|
131
|
+
constructor(retryAfterSeconds) {
|
|
132
|
+
super('Too many authentication attempts', 429, 'RATE_LIMITED');
|
|
133
|
+
if (retryAfterSeconds !== undefined)
|
|
134
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
76
137
|
export function isForgeError(err) {
|
|
77
138
|
return err instanceof ForgeError;
|
|
78
139
|
}
|
package/dist/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
|
package/dist/files.d.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type { ApiContext } from '@forge-cms/api';
|
|
|
2
2
|
import type { AnyForgeCmsRuntime } from './runtime.js';
|
|
3
3
|
export interface FileHandlerOptions<TEnv = unknown> {
|
|
4
4
|
runtime: AnyForgeCmsRuntime<TEnv>;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* `cache-control` for a hit served to an **anonymous** caller. Defaults to a conservative minute. A
|
|
7
|
+
* hit for an authenticated caller is always `private, no-store` (spec 067): shared caches must not
|
|
8
|
+
* hand a file one user may read to another who may not.
|
|
9
|
+
*/
|
|
6
10
|
cacheControl?: string;
|
|
7
11
|
}
|
|
8
12
|
/**
|
|
@@ -19,6 +23,16 @@ export interface FileHandlerOptions<TEnv = unknown> {
|
|
|
19
23
|
* handleFile({ request: toWebRequest(event), params: { key: getRouterParam(event, 'key') ?? '' } }, { runtime })
|
|
20
24
|
* );
|
|
21
25
|
* ```
|
|
26
|
+
*
|
|
27
|
+
* **Access (spec 067).** A file is served only as part of the upload document that owns it: the document
|
|
28
|
+
* of the key's collection whose Forge-recorded `_storageKey` is this key. That document must be readable
|
|
29
|
+
* by the caller through the normal read pipeline (collection and row access, draft visibility), exactly
|
|
30
|
+
* as the Local API `findByID` with `overrideAccess: false` allows. It applies the collection's own
|
|
31
|
+
* `access.read`, **not** route-level options such as `requireAuth`/`allowedRoles` — a collection
|
|
32
|
+
* whose reads are gated only at its route must declare `access.read` to gate its files too. A key no
|
|
33
|
+
* document owns, or one whose document the
|
|
34
|
+
* caller cannot read, is the same `404` — it confirms nothing. A public bucket or CDN URL is outside
|
|
35
|
+
* this handler and outside Forge's access control.
|
|
22
36
|
*/
|
|
23
37
|
export declare function handleFile<TEnv = unknown>(context: ApiContext<TEnv>, options: FileHandlerOptions<TEnv>): Promise<Response>;
|
|
24
38
|
//# sourceMappingURL=files.d.ts.map
|
package/dist/files.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAKvD,MAAM,WAAW,kBAAkB,CAAC,IAAI,GAAG,OAAO;IAChD,OAAO,EAAE,kBAAkB,CAAC,IAAI,CAAC,CAAC;IAClC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,UAAU,CAAC,IAAI,GAAG,OAAO,EAC7C,OAAO,EAAE,UAAU,CAAC,IAAI,CAAC,EACzB,OAAO,EAAE,kBAAkB,CAAC,IAAI,CAAC,GAChC,OAAO,CAAC,QAAQ,CAAC,CA8CnB"}
|
package/dist/files.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { AccessDeniedError, NotFoundError } from './errors.js';
|
|
2
|
+
import { resolveOptionalUser } from './handlers.js';
|
|
3
|
+
import { findStorageOwner } from './storage-intents.js';
|
|
1
4
|
/**
|
|
2
5
|
* Serves a stored file back over HTTP.
|
|
3
6
|
*
|
|
@@ -12,35 +15,73 @@
|
|
|
12
15
|
* handleFile({ request: toWebRequest(event), params: { key: getRouterParam(event, 'key') ?? '' } }, { runtime })
|
|
13
16
|
* );
|
|
14
17
|
* ```
|
|
18
|
+
*
|
|
19
|
+
* **Access (spec 067).** A file is served only as part of the upload document that owns it: the document
|
|
20
|
+
* of the key's collection whose Forge-recorded `_storageKey` is this key. That document must be readable
|
|
21
|
+
* by the caller through the normal read pipeline (collection and row access, draft visibility), exactly
|
|
22
|
+
* as the Local API `findByID` with `overrideAccess: false` allows. It applies the collection's own
|
|
23
|
+
* `access.read`, **not** route-level options such as `requireAuth`/`allowedRoles` — a collection
|
|
24
|
+
* whose reads are gated only at its route must declare `access.read` to gate its files too. A key no
|
|
25
|
+
* document owns, or one whose document the
|
|
26
|
+
* caller cannot read, is the same `404` — it confirms nothing. A public bucket or CDN URL is outside
|
|
27
|
+
* this handler and outside Forge's access control.
|
|
15
28
|
*/
|
|
16
29
|
export async function handleFile(context, options) {
|
|
17
30
|
const raw = context.params?.['key'];
|
|
18
|
-
if (!raw)
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
if (!raw)
|
|
32
|
+
return json({ error: 'Missing file key' }, 400);
|
|
33
|
+
let key;
|
|
34
|
+
try {
|
|
35
|
+
key = decodeURIComponent(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return json({ error: 'Malformed file key' }, 400);
|
|
23
39
|
}
|
|
24
|
-
const
|
|
40
|
+
const { runtime } = options;
|
|
25
41
|
try {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
const owner = await findStorageOwner(runtime, key);
|
|
43
|
+
if (!owner)
|
|
44
|
+
return notFound(key);
|
|
45
|
+
const user = await resolveOptionalUser(context, runtime);
|
|
46
|
+
try {
|
|
47
|
+
await runtime.findByID({
|
|
48
|
+
collection: owner.collection,
|
|
49
|
+
id: owner.id,
|
|
50
|
+
user,
|
|
51
|
+
overrideAccess: false,
|
|
52
|
+
depth: 0
|
|
31
53
|
});
|
|
32
54
|
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof NotFoundError || err instanceof AccessDeniedError)
|
|
57
|
+
return notFound(key);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
const object = await runtime.adapters.storage.get(key);
|
|
61
|
+
if (!object?.body)
|
|
62
|
+
return notFound(key);
|
|
33
63
|
return new Response(object.body, {
|
|
34
64
|
status: 200,
|
|
35
65
|
headers: {
|
|
36
66
|
'content-type': object.contentType ?? 'application/octet-stream',
|
|
37
|
-
'cache-control': options.cacheControl ?? 'public, max-age=60',
|
|
67
|
+
'cache-control': user === null ? (options.cacheControl ?? 'public, max-age=60') : 'private, no-store',
|
|
38
68
|
...(object.size !== undefined && { 'content-length': String(object.size) })
|
|
39
69
|
}
|
|
40
70
|
});
|
|
41
71
|
}
|
|
42
|
-
catch
|
|
43
|
-
|
|
72
|
+
catch {
|
|
73
|
+
// No driver or storage message reaches the client (it can name buckets, tables or credentials).
|
|
74
|
+
return json({ error: 'Failed to read file' }, 500);
|
|
44
75
|
}
|
|
45
76
|
}
|
|
77
|
+
function notFound(key) {
|
|
78
|
+
return json({ error: `File '${key}' not found` }, 404);
|
|
79
|
+
}
|
|
80
|
+
/** Error bodies are never cached: an anonymous `404` must not be served to the owner who may read it. */
|
|
81
|
+
function json(body, status) {
|
|
82
|
+
return new Response(JSON.stringify(body), {
|
|
83
|
+
status,
|
|
84
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
46
87
|
//# sourceMappingURL=files.js.map
|
package/dist/files.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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"}
|
package/dist/globals.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CmsUser } from '@forge-cms/core';
|
|
1
|
+
import type { CmsUser, GlobalDefinition } from '@forge-cms/core';
|
|
2
2
|
import type { DatabaseRecord } from '@forge-cms/db';
|
|
3
3
|
import type { OperationContext } from './context.js';
|
|
4
4
|
export interface GlobalBaseArgs {
|
|
@@ -8,17 +8,35 @@ export interface GlobalBaseArgs {
|
|
|
8
8
|
}
|
|
9
9
|
export interface GetGlobalArgs extends GlobalBaseArgs {
|
|
10
10
|
depth?: 0 | 1;
|
|
11
|
+
/** Resolve `localized` fields to this locale (with fallback), for a global declaring `locales` (spec 066). */
|
|
12
|
+
locale?: string;
|
|
11
13
|
}
|
|
12
14
|
export interface UpdateGlobalArgs extends GlobalBaseArgs {
|
|
13
15
|
data: Record<string, unknown>;
|
|
16
|
+
/** Write `localized` fields into this locale only, keeping the others (spec 066). Must be one of the global's `locales`. */
|
|
17
|
+
locale?: string;
|
|
14
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* The global options that exist on the shared collection types but can never apply to a singleton that
|
|
21
|
+
* is never created through `create` nor deleted (spec 066): delete hooks, and `create`/`delete` access
|
|
22
|
+
* rules. Refused at startup instead of being accepted and silently ignored. One message per problem.
|
|
23
|
+
* (Localized fields of globals are checked with collections', by `validateLocalizationSchema`.)
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateGlobalSchema(globals: readonly GlobalDefinition[]): string[];
|
|
15
26
|
/**
|
|
16
27
|
* Reads the singleton global document. Returns `null` if the global has never been written.
|
|
17
28
|
*/
|
|
18
29
|
export declare function getGlobal(ctx: OperationContext, args: GetGlobalArgs): Promise<DatabaseRecord | null>;
|
|
19
30
|
/**
|
|
20
|
-
* Creates or updates the singleton global document. Unlike collections, globals always have
|
|
21
|
-
*
|
|
31
|
+
* Creates or updates the singleton global document. Unlike collections, globals always have exactly one
|
|
32
|
+
* document — the first write creates it, later writes update it.
|
|
33
|
+
*
|
|
34
|
+
* Later writes are **partial**, exactly like a collection `update()` (spec 066): omitted fields keep
|
|
35
|
+
* their stored values (defaults and the draft status apply to the first write only), validation runs on
|
|
36
|
+
* the merged document, and a `locale` write changes that locale of each `localized` field and keeps the
|
|
37
|
+
* others. A query-returning `access.update` rule must match the stored row, and cannot authorize the
|
|
38
|
+
* first write (there is no row to match). Two simultaneous first writes: one commits, the other gets
|
|
39
|
+
* `409 CONCURRENT_MODIFICATION` with nothing written.
|
|
22
40
|
*/
|
|
23
41
|
export declare function updateGlobal(ctx: OperationContext, args: UpdateGlobalArgs): Promise<DatabaseRecord>;
|
|
24
42
|
//# sourceMappingURL=globals.d.ts.map
|
package/dist/globals.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"globals.d.ts","sourceRoot":"","sources":["../src/globals.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,
|
|
1
|
+
{"version":3,"file":"globals.d.ts","sourceRoot":"","sources":["../src/globals.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAwB,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,KAAK,EAAwB,cAAc,EAAiB,MAAM,eAAe,CAAC;AAKzF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAuCrD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,aAAc,SAAQ,cAAc;IACnD,KAAK,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;IACd,8GAA8G;IAC9G,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,cAAc;IACtD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,4HAA4H;IAC5H,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,EAAE,CAsBnF;AAiGD;;GAEG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,gBAAgB,EACrB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CA+DhC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,gBAAgB,EACrB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,CAyJzB"}
|
package/dist/globals.js
CHANGED
|
@@ -1,12 +1,55 @@
|
|
|
1
1
|
import { validateCollection } from '@forge-cms/core';
|
|
2
|
-
import {
|
|
2
|
+
import { isAtomicWriteConditionError, isUniqueConstraintError as isDbUniqueConstraintError } from '@forge-cms/db';
|
|
3
|
+
import { AccessDeniedError, ConcurrentModificationError, ForgeError, InvalidInputError, NotFoundError, ValidationFailedError } from './errors.js';
|
|
3
4
|
import { documentMatches, resolveAccess } from './access.js';
|
|
4
|
-
import {
|
|
5
|
+
import { afterStamp } from './concurrency.js';
|
|
6
|
+
import { applyAutoSlugs, applyFieldDefaults } from './defaults.js';
|
|
5
7
|
import { statusConstraint } from './read-policy.js';
|
|
6
8
|
import { populateRecord } from './populate.js';
|
|
7
9
|
import { runAfterChangeHooks, runAfterOperationHooks, runAfterReadHooks, runBeforeChangeHooks, runBeforeOperationHooks, runBeforeValidateHooks, runFieldHooks } from './hooks.js';
|
|
8
10
|
import { assertWritableFields, filterReadableFields, FieldAccessError } from './field-access.js';
|
|
11
|
+
import { screenHookOutput, screenUpdateInput } from './system-fields.js';
|
|
12
|
+
import { isLocalizedField, resolveLocalizedDocument, storeLocalizedDocument } from './localization.js';
|
|
13
|
+
import { collectWrittenTargets, targetAssertions, verifyTargetsExist } from './relation-lifecycle.js';
|
|
9
14
|
const GLOBAL_ID = 'global';
|
|
15
|
+
/**
|
|
16
|
+
* The global options that exist on the shared collection types but can never apply to a singleton that
|
|
17
|
+
* is never created through `create` nor deleted (spec 066): delete hooks, and `create`/`delete` access
|
|
18
|
+
* rules. Refused at startup instead of being accepted and silently ignored. One message per problem.
|
|
19
|
+
* (Localized fields of globals are checked with collections', by `validateLocalizationSchema`.)
|
|
20
|
+
*/
|
|
21
|
+
export function validateGlobalSchema(globals) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
for (const global of globals) {
|
|
24
|
+
const label = `Global '${global.slug}'`;
|
|
25
|
+
for (const hook of ['beforeDelete', 'afterDelete']) {
|
|
26
|
+
if ((global.hooks?.[hook]?.length ?? 0) > 0) {
|
|
27
|
+
errors.push(`${label} declares ${hook} hooks, but a global is never deleted, so they could never run (spec 066). Remove them.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const rule of ['create', 'delete']) {
|
|
31
|
+
if (global.access?.[rule] !== undefined) {
|
|
32
|
+
errors.push(`${label} declares access.${rule}, but a global only has read and update: its first write is ` +
|
|
33
|
+
`an update and it is never deleted, so this rule could never apply (spec 066). Gate the ` +
|
|
34
|
+
`first write with access.update.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
/** The collection-shaped view of a global the shared pipeline stages (hooks, validation, locales) take. */
|
|
41
|
+
function proxyOf(global) {
|
|
42
|
+
return { ...global, upload: false };
|
|
43
|
+
}
|
|
44
|
+
function assertKnownLocale(global, locale) {
|
|
45
|
+
if (locale === undefined)
|
|
46
|
+
return;
|
|
47
|
+
if (!global.locales?.includes(locale)) {
|
|
48
|
+
throw new InvalidInputError(global.locales && global.locales.length > 0
|
|
49
|
+
? `Unknown locale '${locale}' for global '${global.slug}'; expected one of ${global.locales.map((l) => `'${l}'`).join(', ')}`
|
|
50
|
+
: `Global '${global.slug}' is not localized; locale '${locale}' cannot be written`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
10
53
|
function getGlobalOrThrow(ctx, slug) {
|
|
11
54
|
const global = ctx.getGlobal(slug);
|
|
12
55
|
if (!global)
|
|
@@ -44,7 +87,7 @@ async function prepareGlobalForRead(ctx, global, record, args) {
|
|
|
44
87
|
return null;
|
|
45
88
|
const user = args.user ?? null;
|
|
46
89
|
const overrideAccess = args.overrideAccess !== false;
|
|
47
|
-
const collectionProxy =
|
|
90
|
+
const collectionProxy = proxyOf(global);
|
|
48
91
|
let doc = record;
|
|
49
92
|
if (args.depth === 1) {
|
|
50
93
|
doc = await populateRecord(doc, collectionProxy, ctx, {
|
|
@@ -55,6 +98,9 @@ async function prepareGlobalForRead(ctx, global, record, args) {
|
|
|
55
98
|
if (args.overrideAccess === false) {
|
|
56
99
|
doc = await filterReadableFields(doc, collectionProxy, user);
|
|
57
100
|
}
|
|
101
|
+
if (args.locale !== undefined) {
|
|
102
|
+
doc = resolveLocalizedDocument(doc, collectionProxy, args.locale);
|
|
103
|
+
}
|
|
58
104
|
const withFieldHooks = await runFieldHooks(collectionProxy, 'afterRead', {
|
|
59
105
|
data: doc,
|
|
60
106
|
operation: 'read',
|
|
@@ -71,8 +117,19 @@ export async function getGlobal(ctx, args) {
|
|
|
71
117
|
const user = args.user ?? null;
|
|
72
118
|
const overrideAccess = args.overrideAccess !== false;
|
|
73
119
|
await runBeforeOperationHooks({ ...global, upload: false }, { operation: 'read', user, overrideAccess });
|
|
74
|
-
await checkGlobalAccess(global, 'read', args);
|
|
120
|
+
const decision = await checkGlobalAccess(global, 'read', args);
|
|
75
121
|
const record = await ctx.adapters.database.findById(`_global_${global.slug}`, GLOBAL_ID);
|
|
122
|
+
// Spec 066: a query-returning read rule is a row-level grant, as on a collection read. A global row
|
|
123
|
+
// it does not match reads as `null` — the same answer as "never configured", confirming nothing.
|
|
124
|
+
if (record && decision.where && !documentMatches(record, decision.where)) {
|
|
125
|
+
await runAfterOperationHooks(proxyOf(global), {
|
|
126
|
+
operation: 'read',
|
|
127
|
+
user,
|
|
128
|
+
overrideAccess,
|
|
129
|
+
result: null
|
|
130
|
+
});
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
76
133
|
// Draft visibility (spec 058 §8): a global was previously written but never gated its own
|
|
77
134
|
// `_status` on read, so an anonymous caller could read a global's unpublished draft content the
|
|
78
135
|
// exact same way an anonymous single-document read of a `drafts: true` collection cannot. Mirrors
|
|
@@ -94,23 +151,39 @@ export async function getGlobal(ctx, args) {
|
|
|
94
151
|
return doc;
|
|
95
152
|
}
|
|
96
153
|
/**
|
|
97
|
-
* Creates or updates the singleton global document. Unlike collections, globals always have
|
|
98
|
-
*
|
|
154
|
+
* Creates or updates the singleton global document. Unlike collections, globals always have exactly one
|
|
155
|
+
* document — the first write creates it, later writes update it.
|
|
156
|
+
*
|
|
157
|
+
* Later writes are **partial**, exactly like a collection `update()` (spec 066): omitted fields keep
|
|
158
|
+
* their stored values (defaults and the draft status apply to the first write only), validation runs on
|
|
159
|
+
* the merged document, and a `locale` write changes that locale of each `localized` field and keeps the
|
|
160
|
+
* others. A query-returning `access.update` rule must match the stored row, and cannot authorize the
|
|
161
|
+
* first write (there is no row to match). Two simultaneous first writes: one commits, the other gets
|
|
162
|
+
* `409 CONCURRENT_MODIFICATION` with nothing written.
|
|
99
163
|
*/
|
|
100
164
|
export async function updateGlobal(ctx, args) {
|
|
101
165
|
const global = getGlobalOrThrow(ctx, args.global);
|
|
102
166
|
const user = args.user ?? null;
|
|
103
167
|
const overrideAccess = args.overrideAccess !== false;
|
|
104
|
-
const collectionProxy =
|
|
168
|
+
const collectionProxy = proxyOf(global);
|
|
169
|
+
assertKnownLocale(global, args.locale);
|
|
105
170
|
await runBeforeOperationHooks(collectionProxy, { operation: 'update', user, overrideAccess });
|
|
106
171
|
const existing = await ctx.adapters.database.findById(`_global_${global.slug}`, GLOBAL_ID);
|
|
107
|
-
|
|
172
|
+
const operation = existing ? 'update' : 'create';
|
|
173
|
+
const decision = await checkGlobalAccess(global, 'update', {
|
|
108
174
|
...args,
|
|
109
175
|
...(existing !== null && { doc: existing })
|
|
110
176
|
});
|
|
177
|
+
if (decision.where && !(existing && documentMatches(existing, decision.where))) {
|
|
178
|
+
throw new AccessDeniedError();
|
|
179
|
+
}
|
|
180
|
+
// Spec 063: a global row's `id`/timestamps/`_storageKey` belong to Forge. Echoes of the stored row
|
|
181
|
+
// are dropped; before the first write there is nothing to echo, so any of them is refused.
|
|
182
|
+
const stored = existing ?? {};
|
|
183
|
+
const input = screenUpdateInput(args.data, stored);
|
|
111
184
|
if (args.overrideAccess === false) {
|
|
112
185
|
try {
|
|
113
|
-
await assertWritableFields(
|
|
186
|
+
await assertWritableFields(input, collectionProxy, user, operation);
|
|
114
187
|
}
|
|
115
188
|
catch (err) {
|
|
116
189
|
if (err instanceof FieldAccessError)
|
|
@@ -118,33 +191,61 @@ export async function updateGlobal(ctx, args) {
|
|
|
118
191
|
throw err;
|
|
119
192
|
}
|
|
120
193
|
}
|
|
121
|
-
|
|
194
|
+
// A locale write merges into the stored per-locale maps, so it is derived from `existing` and must
|
|
195
|
+
// commit only while the row is still the one it was merged from (compare-and-set below).
|
|
196
|
+
const localized = storeLocalizedDocument(input, collectionProxy, args.locale, existing ?? undefined);
|
|
197
|
+
const mergesLocales = existing !== null &&
|
|
198
|
+
args.locale !== undefined &&
|
|
199
|
+
Object.keys(input).some((name) => {
|
|
200
|
+
const field = global.fields[name];
|
|
201
|
+
return field !== undefined && isLocalizedField(field);
|
|
202
|
+
});
|
|
203
|
+
const seeded = existing
|
|
204
|
+
? applyAutoSlugs(collectionProxy, localized, existing)
|
|
205
|
+
: applyAutoSlugs(collectionProxy, applyFieldDefaults(collectionProxy, localized));
|
|
122
206
|
let data = await runRejectableStage(async () => runBeforeValidateHooks(collectionProxy, {
|
|
123
|
-
operation
|
|
207
|
+
operation,
|
|
124
208
|
data: await runFieldHooks(collectionProxy, 'beforeValidate', {
|
|
125
209
|
data: seeded,
|
|
126
|
-
|
|
210
|
+
...(existing !== null && { previousData: existing }),
|
|
211
|
+
operation,
|
|
127
212
|
user,
|
|
128
213
|
overrideAccess
|
|
129
214
|
}),
|
|
215
|
+
...(existing !== null && { previousData: existing }),
|
|
130
216
|
user,
|
|
131
217
|
overrideAccess
|
|
132
218
|
}), 'beforeValidate hook');
|
|
133
|
-
|
|
219
|
+
data = screenHookOutput(data, stored, 'beforeValidate', `global '${global.slug}'`);
|
|
220
|
+
if (global.drafts === true && !existing && data._status === undefined) {
|
|
134
221
|
data = { ...data, _status: 'draft' };
|
|
135
222
|
}
|
|
136
223
|
if (data._status !== undefined && data._status !== 'draft' && data._status !== 'published') {
|
|
137
224
|
throw new InvalidInputError(`Invalid status '${String(data._status)}', expected 'draft' or 'published'`);
|
|
138
225
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
226
|
+
if (existing) {
|
|
227
|
+
// As a collection update: validate the merged document, report only what the caller can act on.
|
|
228
|
+
const validation = validateCollection(collectionProxy, { ...existing, ...data });
|
|
229
|
+
if (!validation.valid) {
|
|
230
|
+
const relevant = validation.errors.filter((e) => {
|
|
231
|
+
const top = e.field.split('.')[0] ?? e.field;
|
|
232
|
+
return data[top] !== undefined || existing[top] === undefined;
|
|
233
|
+
});
|
|
234
|
+
if (relevant.length > 0)
|
|
235
|
+
throw new ValidationFailedError(relevant);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const validation = validateCollection(collectionProxy, data);
|
|
240
|
+
if (!validation.valid)
|
|
241
|
+
throw new ValidationFailedError(validation.errors);
|
|
242
|
+
}
|
|
142
243
|
data = await runRejectableStage(async () => runBeforeChangeHooks(collectionProxy, {
|
|
143
|
-
operation
|
|
244
|
+
operation,
|
|
144
245
|
data: await runFieldHooks(collectionProxy, 'beforeChange', {
|
|
145
246
|
data,
|
|
146
247
|
...(existing !== null && { previousData: existing }),
|
|
147
|
-
operation
|
|
248
|
+
operation,
|
|
148
249
|
user,
|
|
149
250
|
overrideAccess
|
|
150
251
|
}),
|
|
@@ -152,16 +253,22 @@ export async function updateGlobal(ctx, args) {
|
|
|
152
253
|
user,
|
|
153
254
|
overrideAccess
|
|
154
255
|
}), 'beforeChange hook');
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
256
|
+
data = screenHookOutput(data, stored, 'beforeChange', `global '${global.slug}'`);
|
|
257
|
+
// A global's relation/upload targets must exist, now and when it commits (spec 064 §4) — a global
|
|
258
|
+
// reference restricts deletion of its target, so it must never be written pointing at nothing.
|
|
259
|
+
const targets = collectWrittenTargets(global.fields, data, existing ?? undefined);
|
|
260
|
+
if (targets.size > 0)
|
|
261
|
+
await verifyTargetsExist(ctx, targets);
|
|
262
|
+
const record = await writeGlobal(ctx, {
|
|
263
|
+
global,
|
|
264
|
+
data,
|
|
265
|
+
existing,
|
|
266
|
+
compareAndSet: mergesLocales,
|
|
267
|
+
accessWhere: decision.where,
|
|
268
|
+
assertions: targets.size > 0 ? targetAssertions(targets) : []
|
|
269
|
+
});
|
|
163
270
|
await runAfterChangeHooks(collectionProxy, {
|
|
164
|
-
operation
|
|
271
|
+
operation,
|
|
165
272
|
data,
|
|
166
273
|
...(existing !== null && { previousData: existing }),
|
|
167
274
|
result: record,
|
|
@@ -169,7 +276,9 @@ export async function updateGlobal(ctx, args) {
|
|
|
169
276
|
user,
|
|
170
277
|
overrideAccess
|
|
171
278
|
});
|
|
172
|
-
|
|
279
|
+
// Being allowed to write the global is not permission to read it back (spec 068).
|
|
280
|
+
const readable = args.overrideAccess !== false || (await canReadGlobal(global, record, user));
|
|
281
|
+
const doc = readable ? await prepareGlobalForRead(ctx, global, record, args) : { id: GLOBAL_ID };
|
|
173
282
|
const result = doc ?? record;
|
|
174
283
|
await runAfterOperationHooks(collectionProxy, {
|
|
175
284
|
operation: 'update',
|
|
@@ -179,4 +288,83 @@ export async function updateGlobal(ctx, args) {
|
|
|
179
288
|
});
|
|
180
289
|
return result;
|
|
181
290
|
}
|
|
291
|
+
/** The read gate `getGlobal` applies, as a yes/no for the row just written. */
|
|
292
|
+
async function canReadGlobal(global, record, user) {
|
|
293
|
+
let decision;
|
|
294
|
+
try {
|
|
295
|
+
decision = await checkGlobalAccess(global, 'read', { user, overrideAccess: false });
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (err instanceof AccessDeniedError)
|
|
299
|
+
return false;
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
if (decision.where && !documentMatches(record, decision.where))
|
|
303
|
+
return false;
|
|
304
|
+
const status = statusConstraint(proxyOf(global), undefined, user, false, 'all');
|
|
305
|
+
return !status || documentMatches(record, status);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Persists a prepared global write. The first write is a `create` of the fixed `global` row: of two
|
|
309
|
+
* simultaneous first writers, the row's primary key lets exactly one commit, and the other becomes a
|
|
310
|
+
* `409` instead of leaking the internal table's unique-constraint error (spec 066). A write carrying
|
|
311
|
+
* relation-target assertions (spec 064 §4), or a locale merge that must not overwrite a concurrent one
|
|
312
|
+
* (`compareAndSet`, on `updated_at`), is one `atomicWrite`; a failed condition is a `409`, nothing written.
|
|
313
|
+
*/
|
|
314
|
+
async function writeGlobal(ctx, input) {
|
|
315
|
+
const { global, data, existing, assertions } = input;
|
|
316
|
+
const table = `_global_${global.slug}`;
|
|
317
|
+
const database = ctx.adapters.database;
|
|
318
|
+
const concurrent = (message) => new ConcurrentModificationError(global.slug, GLOBAL_ID, message);
|
|
319
|
+
const conditions = [
|
|
320
|
+
...(input.compareAndSet && existing ? [{ updated_at: existing.updated_at }] : []),
|
|
321
|
+
...(input.accessWhere !== undefined ? [input.accessWhere] : [])
|
|
322
|
+
];
|
|
323
|
+
const condition = conditions.length === 0
|
|
324
|
+
? undefined
|
|
325
|
+
: conditions.length === 1
|
|
326
|
+
? conditions[0]
|
|
327
|
+
: { and: conditions };
|
|
328
|
+
const write = !existing
|
|
329
|
+
? { type: 'create', collection: table, data: { ...data, id: GLOBAL_ID } }
|
|
330
|
+
: condition !== undefined
|
|
331
|
+
? {
|
|
332
|
+
type: 'updateIf',
|
|
333
|
+
collection: table,
|
|
334
|
+
id: GLOBAL_ID,
|
|
335
|
+
data,
|
|
336
|
+
condition: { targetMatches: condition },
|
|
337
|
+
requireApplied: true
|
|
338
|
+
}
|
|
339
|
+
: { type: 'update', collection: table, id: GLOBAL_ID, data };
|
|
340
|
+
if (input.compareAndSet && existing)
|
|
341
|
+
await afterStamp(existing.updated_at);
|
|
342
|
+
try {
|
|
343
|
+
if (assertions.length === 0 && write.type === 'create') {
|
|
344
|
+
return await database.create(table, write.data);
|
|
345
|
+
}
|
|
346
|
+
if (assertions.length === 0 && write.type === 'update') {
|
|
347
|
+
return await database.update(table, GLOBAL_ID, write.data);
|
|
348
|
+
}
|
|
349
|
+
const results = await database.atomicWrite([...assertions, write]);
|
|
350
|
+
const written = results[assertions.length];
|
|
351
|
+
if (written?.type === 'create' || written?.type === 'update')
|
|
352
|
+
return written.record;
|
|
353
|
+
if (written?.type === 'updateIf' && written.applied)
|
|
354
|
+
return written.record;
|
|
355
|
+
throw new Error('atomicWrite returned no global record');
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
if (!existing && isDbUniqueConstraintError(err) && err.collection === table) {
|
|
359
|
+
throw concurrent(`Global '${global.slug}' was first written by another request at the same time; nothing was ` +
|
|
360
|
+
`written. Reload and try again.`);
|
|
361
|
+
}
|
|
362
|
+
if (isAtomicWriteConditionError(err)) {
|
|
363
|
+
throw concurrent(`Global '${global.slug}' was changed by another request while this write was in progress (another ` +
|
|
364
|
+
`locale was edited, it no longer matches the caller's update access, or a document it references ` +
|
|
365
|
+
`was deleted); nothing was written. Reload and try again.`);
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
182
370
|
//# sourceMappingURL=globals.js.map
|