@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.
Files changed (71) hide show
  1. package/dist/auth-handlers.d.ts +43 -1
  2. package/dist/auth-handlers.d.ts.map +1 -1
  3. package/dist/auth-handlers.js +64 -18
  4. package/dist/auth-handlers.js.map +1 -1
  5. package/dist/auth-managed.d.ts +19 -0
  6. package/dist/auth-managed.d.ts.map +1 -0
  7. package/dist/auth-managed.js +24 -0
  8. package/dist/auth-managed.js.map +1 -0
  9. package/dist/body.d.ts +28 -0
  10. package/dist/body.d.ts.map +1 -0
  11. package/dist/body.js +81 -0
  12. package/dist/body.js.map +1 -0
  13. package/dist/concurrency.d.ts +8 -0
  14. package/dist/concurrency.d.ts.map +1 -0
  15. package/dist/concurrency.js +15 -0
  16. package/dist/concurrency.js.map +1 -0
  17. package/dist/context.d.ts +5 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/errors.d.ts +46 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +61 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/files.d.ts +15 -1
  24. package/dist/files.d.ts.map +1 -1
  25. package/dist/files.js +55 -14
  26. package/dist/files.js.map +1 -1
  27. package/dist/globals.d.ts +21 -3
  28. package/dist/globals.d.ts.map +1 -1
  29. package/dist/globals.js +216 -28
  30. package/dist/globals.js.map +1 -1
  31. package/dist/handlers.d.ts +3 -2
  32. package/dist/handlers.d.ts.map +1 -1
  33. package/dist/handlers.js +51 -49
  34. package/dist/handlers.js.map +1 -1
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +6 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/localization.d.ts +17 -0
  40. package/dist/localization.d.ts.map +1 -1
  41. package/dist/localization.js +56 -0
  42. package/dist/localization.js.map +1 -1
  43. package/dist/operations.d.ts +51 -12
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations.js +728 -125
  46. package/dist/operations.js.map +1 -1
  47. package/dist/relation-integrity.d.ts +31 -13
  48. package/dist/relation-integrity.d.ts.map +1 -1
  49. package/dist/relation-integrity.js +62 -6
  50. package/dist/relation-integrity.js.map +1 -1
  51. package/dist/relation-lifecycle.d.ts +95 -0
  52. package/dist/relation-lifecycle.d.ts.map +1 -0
  53. package/dist/relation-lifecycle.js +424 -0
  54. package/dist/relation-lifecycle.js.map +1 -0
  55. package/dist/runtime.d.ts +21 -0
  56. package/dist/runtime.d.ts.map +1 -1
  57. package/dist/runtime.js +112 -20
  58. package/dist/runtime.js.map +1 -1
  59. package/dist/storage-intents.d.ts +96 -0
  60. package/dist/storage-intents.d.ts.map +1 -0
  61. package/dist/storage-intents.js +217 -0
  62. package/dist/storage-intents.js.map +1 -0
  63. package/dist/system-fields.d.ts +27 -0
  64. package/dist/system-fields.d.ts.map +1 -0
  65. package/dist/system-fields.js +74 -0
  66. package/dist/system-fields.js.map +1 -0
  67. package/dist/versions.d.ts +73 -1
  68. package/dist/versions.d.ts.map +1 -1
  69. package/dist/versions.js +204 -17
  70. package/dist/versions.js.map +1 -1
  71. 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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
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
- /** `cache-control` for a hit. Defaults to a conservative minute. */
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
@@ -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;AAEvD,MAAM,WAAW,kBAAkB,CAAC,IAAI,GAAG,OAAO;IAChD,OAAO,EAAE,kBAAkB,CAAC,IAAI,CAAC,CAAC;IAClC,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;GAcG;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,CAkCnB"}
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 new Response(JSON.stringify({ error: 'Missing file key' }), {
20
- status: 400,
21
- headers: { 'content-type': 'application/json' }
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 key = decodeURIComponent(raw);
40
+ const { runtime } = options;
25
41
  try {
26
- const object = await options.runtime.adapters.storage.get(key);
27
- if (!object?.body) {
28
- return new Response(JSON.stringify({ error: `File '${key}' not found` }), {
29
- status: 404,
30
- headers: { 'content-type': 'application/json' }
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 (err) {
43
- return new Response(JSON.stringify({ error: err instanceof Error ? err.message : 'Failed to read file' }), { status: 500, headers: { 'content-type': 'application/json' } });
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":"AASA;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAyB,EACzB,OAAiC;IAEjC,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC,EAAE;YACjE,MAAM,EAAE,GAAG;YACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;SAChD,CAAC,CAAC;IACL,CAAC;IAED,MAAM,GAAG,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;IAEpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/D,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC;YAClB,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,SAAS,GAAG,aAAa,EAAE,CAAC,EAAE;gBACxE,MAAM,EAAE,GAAG;gBACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;aAChD,CAAC,CAAC;QACL,CAAC;QAED,OAAO,IAAI,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE;YAC/B,MAAM,EAAE,GAAG;YACX,OAAO,EAAE;gBACP,cAAc,EAAE,MAAM,CAAC,WAAW,IAAI,0BAA0B;gBAChE,eAAe,EAAE,OAAO,CAAC,YAAY,IAAI,oBAAoB;gBAC7D,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,gBAAgB,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;aAC5E;SACF,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,IAAI,QAAQ,CACjB,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB,EAAE,CAAC,EACrF,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,EAAE,CACjE,CAAC;IACJ,CAAC;AACH,CAAC"}
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
- * exactly one document — the first write creates it, subsequent writes update it.
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
@@ -1 +1 @@
1
- {"version":3,"file":"globals.d.ts","sourceRoot":"","sources":["../src/globals.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAoB,MAAM,iBAAiB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AA0BrD,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;CACf;AAED,MAAM,WAAW,gBAAiB,SAAQ,cAAc;IACtD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AA6ED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,GAAG,EAAE,gBAAgB,EACrB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAmDhC;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,gBAAgB,EACrB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,CAsGzB"}
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 { AccessDeniedError, ForgeError, InvalidInputError, NotFoundError, ValidationFailedError } from './errors.js';
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 { applyFieldDefaults } from './defaults.js';
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 = { ...global, upload: false };
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
- * exactly one document — the first write creates it, subsequent writes update it.
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 = { ...global, upload: false };
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
- await checkGlobalAccess(global, 'update', {
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(args.data, collectionProxy, user, existing ? 'update' : 'create');
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
- const seeded = applyFieldDefaults(collectionProxy, args.data);
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: existing ? 'update' : 'create',
207
+ operation,
124
208
  data: await runFieldHooks(collectionProxy, 'beforeValidate', {
125
209
  data: seeded,
126
- operation: existing ? 'update' : 'create',
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
- if (global.drafts === true && data._status === undefined) {
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
- const validation = validateCollection(collectionProxy, data);
140
- if (!validation.valid)
141
- throw new ValidationFailedError(validation.errors);
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: existing ? 'update' : 'create',
244
+ operation,
144
245
  data: await runFieldHooks(collectionProxy, 'beforeChange', {
145
246
  data,
146
247
  ...(existing !== null && { previousData: existing }),
147
- operation: existing ? 'update' : 'create',
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
- const globalCollection = `_global_${global.slug}`;
156
- let record;
157
- if (existing) {
158
- record = await ctx.adapters.database.update(globalCollection, GLOBAL_ID, data);
159
- }
160
- else {
161
- record = await ctx.adapters.database.create(globalCollection, { ...data, id: GLOBAL_ID });
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: existing ? 'update' : 'create',
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
- const doc = await prepareGlobalForRead(ctx, global, record, args);
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