@beechcms/core 0.8.1 → 0.9.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 (163) hide show
  1. package/dist/antivirus/noop-antivirus.provider.d.ts.map +1 -1
  2. package/dist/antivirus/virustotal-antivirus.provider.d.ts.map +1 -1
  3. package/dist/auth/account-email.d.ts +3 -0
  4. package/dist/auth/account-email.d.ts.map +1 -0
  5. package/dist/auth/account-email.js +9 -0
  6. package/dist/auth/password-reset-token.repository.d.ts +13 -2
  7. package/dist/auth/password-reset-token.repository.d.ts.map +1 -1
  8. package/dist/auth/user.repository.d.ts +69 -4
  9. package/dist/auth/user.repository.d.ts.map +1 -1
  10. package/dist/common/canonical-json.d.ts +2 -1
  11. package/dist/common/canonical-json.d.ts.map +1 -1
  12. package/dist/common/canonical-json.js +5 -3
  13. package/dist/content/content-scan.repository.d.ts +1 -1
  14. package/dist/content/content.repository.d.ts +156 -5
  15. package/dist/content/content.repository.d.ts.map +1 -1
  16. package/dist/content/content.repository.js +62 -0
  17. package/dist/content/seed-media-purge.repository.d.ts +34 -0
  18. package/dist/content/seed-media-purge.repository.d.ts.map +1 -0
  19. package/dist/content/seed-media-purge.repository.js +3 -0
  20. package/dist/content/seed.repository.d.ts +14 -0
  21. package/dist/content/seed.repository.d.ts.map +1 -1
  22. package/dist/dashboard-layout/content-view.d.ts +757 -0
  23. package/dist/dashboard-layout/content-view.d.ts.map +1 -0
  24. package/dist/dashboard-layout/content-view.js +285 -0
  25. package/dist/dashboard-layout/content-view.repository.d.ts +39 -0
  26. package/dist/dashboard-layout/content-view.repository.d.ts.map +1 -0
  27. package/dist/dashboard-layout/content-view.repository.js +3 -0
  28. package/dist/dashboard-layout/dashboard-layout.d.ts +5 -0
  29. package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
  30. package/dist/dashboard-layout/dashboard-layout.js +17 -9
  31. package/dist/dashboard-layout/kanban/kanban.d.ts +32 -18
  32. package/dist/dashboard-layout/kanban/kanban.d.ts.map +1 -1
  33. package/dist/dashboard-layout/kanban/kanban.js +45 -21
  34. package/dist/dashboard-layout/seed-layout.d.ts +22 -31
  35. package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
  36. package/dist/dashboard-layout/seed-layout.js +93 -18
  37. package/dist/dashboard-layout/seed-layout.repository.d.ts +1 -5
  38. package/dist/dashboard-layout/seed-layout.repository.d.ts.map +1 -1
  39. package/dist/engine/ddl/ddl.d.ts +31 -0
  40. package/dist/engine/ddl/ddl.d.ts.map +1 -1
  41. package/dist/engine/ddl/ddl.js +92 -13
  42. package/dist/engine/ddl/schema-mutator.d.ts +3 -0
  43. package/dist/engine/ddl/schema-mutator.d.ts.map +1 -1
  44. package/dist/engine/ddl/seed-purge-guards.d.ts +7 -0
  45. package/dist/engine/ddl/seed-purge-guards.d.ts.map +1 -0
  46. package/dist/engine/ddl/seed-purge-guards.js +19 -0
  47. package/dist/engine/engine.d.ts +4 -3
  48. package/dist/engine/engine.d.ts.map +1 -1
  49. package/dist/engine/engine.js +4 -3
  50. package/dist/engine/introspection/relation-audit.d.ts +46 -0
  51. package/dist/engine/introspection/relation-audit.d.ts.map +1 -0
  52. package/dist/engine/introspection/relation-audit.js +103 -0
  53. package/dist/engine/introspection/schema-fingerprint.d.ts +2 -1
  54. package/dist/engine/introspection/schema-fingerprint.d.ts.map +1 -1
  55. package/dist/engine/introspection/schema-fingerprint.js +8 -3
  56. package/dist/engine/privacy/policies.d.ts +1 -1
  57. package/dist/engine/privacy/policies.d.ts.map +1 -1
  58. package/dist/engine/privacy/policies.js +12 -6
  59. package/dist/engine/privacy/privacy.service.d.ts +13 -0
  60. package/dist/engine/privacy/privacy.service.d.ts.map +1 -1
  61. package/dist/engine/privacy/privacy.service.js +24 -0
  62. package/dist/engine/query/active-clause.d.ts +19 -0
  63. package/dist/engine/query/active-clause.d.ts.map +1 -0
  64. package/dist/engine/query/active-clause.js +27 -0
  65. package/dist/engine/query/query.d.ts +14 -1
  66. package/dist/engine/query/query.d.ts.map +1 -1
  67. package/dist/engine/query/query.js +105 -13
  68. package/dist/engine/seeds/candidate-ids.d.ts +14 -0
  69. package/dist/engine/seeds/candidate-ids.d.ts.map +1 -0
  70. package/dist/engine/seeds/candidate-ids.js +40 -0
  71. package/dist/engine/seeds/seed-definition-diff.d.ts +38 -0
  72. package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -0
  73. package/dist/engine/seeds/seed-definition-diff.js +51 -0
  74. package/dist/engine/seeds/seed-registry.d.ts +17 -0
  75. package/dist/engine/seeds/seed-registry.d.ts.map +1 -1
  76. package/dist/engine/seeds/seed-registry.js +27 -0
  77. package/dist/engine/seeds/seed-types-generator.d.ts.map +1 -1
  78. package/dist/engine/seeds/seed-types-generator.js +4 -5
  79. package/dist/engine/seeds/seed-validation.d.ts +5 -0
  80. package/dist/engine/seeds/seed-validation.d.ts.map +1 -1
  81. package/dist/engine/seeds/seed-validation.js +39 -19
  82. package/dist/engine/seeds/tag-migration.d.ts +73 -0
  83. package/dist/engine/seeds/tag-migration.d.ts.map +1 -0
  84. package/dist/engine/seeds/tag-migration.js +212 -0
  85. package/dist/engine/seeds/tag-options.d.ts +15 -0
  86. package/dist/engine/seeds/tag-options.d.ts.map +1 -0
  87. package/dist/engine/seeds/tag-options.js +111 -0
  88. package/dist/engine/seeds/tag-value.d.ts +31 -0
  89. package/dist/engine/seeds/tag-value.d.ts.map +1 -0
  90. package/dist/engine/seeds/tag-value.js +51 -0
  91. package/dist/engine/types.d.ts +59 -7
  92. package/dist/engine/types.d.ts.map +1 -1
  93. package/dist/engine/validation/cache.d.ts.map +1 -1
  94. package/dist/engine/validation/cache.js +20 -4
  95. package/dist/engine/validation/index.d.ts +12 -1
  96. package/dist/engine/validation/index.d.ts.map +1 -1
  97. package/dist/engine/validation/index.js +29 -33
  98. package/dist/engine/validation/primitives.d.ts +12 -0
  99. package/dist/engine/validation/primitives.d.ts.map +1 -1
  100. package/dist/engine/validation/primitives.js +22 -0
  101. package/dist/engine/validation/richtext-sanitizer.d.ts +9 -1
  102. package/dist/engine/validation/richtext-sanitizer.d.ts.map +1 -1
  103. package/dist/engine/validation/richtext-sanitizer.js +36 -7
  104. package/dist/engine/validation/schema-builders.d.ts +16 -0
  105. package/dist/engine/validation/schema-builders.d.ts.map +1 -1
  106. package/dist/engine/validation/schema-builders.js +125 -18
  107. package/dist/index.d.ts +34 -11
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +34 -11
  110. package/dist/media/file-types.d.ts +12 -0
  111. package/dist/media/file-types.d.ts.map +1 -1
  112. package/dist/media/file-types.js +24 -4
  113. package/dist/media/magic-bytes.d.ts.map +1 -1
  114. package/dist/media/magic-bytes.js +19 -9
  115. package/dist/media/media-transform.d.ts +8 -4
  116. package/dist/media/media-transform.d.ts.map +1 -1
  117. package/dist/media/media-transform.js +10 -6
  118. package/dist/media/media.repository.d.ts +5 -0
  119. package/dist/media/media.repository.d.ts.map +1 -1
  120. package/dist/notifications/notification-service.d.ts +1 -1
  121. package/dist/notifications/notification.repository.d.ts +11 -2
  122. package/dist/notifications/notification.repository.d.ts.map +1 -1
  123. package/dist/oauth/authorization-code.repository.d.ts +10 -5
  124. package/dist/oauth/authorization-code.repository.d.ts.map +1 -1
  125. package/dist/oauth/pkce.d.ts +1 -1
  126. package/dist/oauth/role-guard.d.ts +30 -11
  127. package/dist/oauth/role-guard.d.ts.map +1 -1
  128. package/dist/oauth/role-guard.js +27 -5
  129. package/dist/oauth/scopes.d.ts +1 -1
  130. package/dist/oauth/scopes.d.ts.map +1 -1
  131. package/dist/oauth/token.repository.d.ts +2 -0
  132. package/dist/oauth/token.repository.d.ts.map +1 -1
  133. package/dist/queue/queue.interface.d.ts +9 -1
  134. package/dist/queue/queue.interface.d.ts.map +1 -1
  135. package/dist/queue/queue.stub.d.ts +1 -1
  136. package/dist/queue/queue.stub.d.ts.map +1 -1
  137. package/dist/queue/queue.stub.js +2 -3
  138. package/dist/queue/scheduler.stub.d.ts.map +1 -1
  139. package/dist/rate-limit/token-bucket-rate-limiter.d.ts +7 -0
  140. package/dist/rate-limit/token-bucket-rate-limiter.d.ts.map +1 -1
  141. package/dist/rate-limit/token-bucket-rate-limiter.js +66 -0
  142. package/dist/rbac/invitation.repository.d.ts +28 -10
  143. package/dist/rbac/invitation.repository.d.ts.map +1 -1
  144. package/dist/rbac/types.d.ts +40 -28
  145. package/dist/rbac/types.d.ts.map +1 -1
  146. package/dist/rbac/types.js +2 -1
  147. package/dist/schema/manifest-seeds.js +3 -3
  148. package/dist/search/search.repository.d.ts +5 -4
  149. package/dist/search/search.repository.d.ts.map +1 -1
  150. package/dist/search/vector-extractor.d.ts.map +1 -1
  151. package/dist/search/vector-extractor.js +23 -2
  152. package/dist/search/vector.repository.d.ts +2 -1
  153. package/dist/search/vector.repository.d.ts.map +1 -1
  154. package/dist/security/time-trap-token.repository.d.ts +5 -3
  155. package/dist/security/time-trap-token.repository.d.ts.map +1 -1
  156. package/dist/settings/site-settings.repository.d.ts +8 -0
  157. package/dist/settings/site-settings.repository.d.ts.map +1 -1
  158. package/dist/transfer/flat-seed.d.ts.map +1 -1
  159. package/dist/transfer/flat-seed.js +6 -1
  160. package/dist/transfer/row-mapping.d.ts.map +1 -1
  161. package/dist/transfer/row-mapping.js +9 -2
  162. package/package.json +8 -3
  163. package/dist/.tsbuildinfo +0 -1
@@ -7,27 +7,90 @@ export class TokenBucketRateLimiter {
7
7
  refillRatePerSecond;
8
8
  clock;
9
9
  maxIdleTimeSeconds;
10
+ maxBuckets;
11
+ pruneIntervalSeconds;
12
+ lastPruneTimestamp = 0;
10
13
  constructor(options) {
11
14
  this.capacity = options?.capacity ?? 17;
12
15
  // Default: 1 token every 3.53 seconds (~0.283286 tokens/sec)
13
16
  this.refillRatePerSecond = options?.refillRatePerSecond ?? (1 / 3.53);
14
17
  this.clock = options?.clock ?? SystemClock;
15
18
  this.maxIdleTimeSeconds = options?.maxIdleTimeSeconds ?? 3600; // 1 hour idle TTL
19
+ this.maxBuckets = options?.maxBuckets ?? 5000; // Prevent unbounded memory growth
20
+ this.pruneIntervalSeconds = options?.pruneIntervalSeconds ?? 60; // Throttle O(n) scans
16
21
  }
22
+ // Bound the eviction scan so a flood of unique keys (each forcing an eviction attempt)
23
+ // cannot turn checkLimit into an O(maxBuckets) operation per request. Only the oldest
24
+ // slice of the map is examined; if nothing there qualifies, admission of the new key is
25
+ // denied instead of evicting something it shouldn't (see below).
26
+ static MAX_EVICTION_SCAN = 32;
17
27
  pruneExpiredBuckets(now) {
18
28
  if (this.buckets.size < 500)
19
29
  return;
30
+ if (now - this.lastPruneTimestamp < this.pruneIntervalSeconds)
31
+ return;
32
+ this.lastPruneTimestamp = now;
20
33
  for (const [key, state] of this.buckets.entries()) {
21
34
  if (now - state.lastRefillTimestamp > this.maxIdleTimeSeconds) {
22
35
  this.buckets.delete(key);
23
36
  }
37
+ else {
38
+ // Since Map preserves insertion order and checkLimit re-inserts on every access,
39
+ // entries are monotonically ordered by lastRefillTimestamp. The first non-expired
40
+ // entry guarantees all subsequent entries are also active, allowing an immediate O(1) exit.
41
+ break;
42
+ }
24
43
  }
25
44
  }
45
+ // Evicts the oldest bucket ONLY if it has refilled to full capacity, in which case it is
46
+ // behaviourally equivalent to an absent bucket and dropping it loses no state. A bucket
47
+ // that hasn't refilled — including one sitting at 0 tokens and actively blocking its key —
48
+ // is never evicted: doing so would let an attacker flood distinct keys to silently reset a
49
+ // victim's exhausted bucket and bypass its limit (#458).
50
+ //
51
+ // When nothing in the scanned slice is eligible, the caller must not admit a new bucket
52
+ // anyway (#634) — that would make the map grow without bound under a distinct-key flood.
53
+ // Instead, report how long until the earliest-refilling scanned bucket would become a
54
+ // legitimate eviction candidate, so the caller can deny admission with a meaningful
55
+ // retryAfterSeconds rather than guessing.
56
+ evictRefilledBucket(now) {
57
+ let scanned = 0;
58
+ let minSecondsUntilRefilled = this.maxIdleTimeSeconds;
59
+ for (const [key, state] of this.buckets.entries()) {
60
+ if (scanned >= TokenBucketRateLimiter.MAX_EVICTION_SCAN)
61
+ break;
62
+ scanned++;
63
+ const elapsed = Math.max(0, now - state.lastRefillTimestamp);
64
+ const refilled = Math.min(this.capacity, state.tokens + elapsed * this.refillRatePerSecond);
65
+ if (refilled >= this.capacity) {
66
+ this.buckets.delete(key);
67
+ return { evicted: true, retryAfterSeconds: 0 };
68
+ }
69
+ if (this.refillRatePerSecond > 0) {
70
+ const secondsUntilRefilled = (this.capacity - refilled) / this.refillRatePerSecond;
71
+ minSecondsUntilRefilled = Math.min(minSecondsUntilRefilled, secondsUntilRefilled);
72
+ }
73
+ }
74
+ return { evicted: false, retryAfterSeconds: Math.max(1, Math.ceil(minSecondsUntilRefilled)) };
75
+ }
26
76
  async checkLimit(key) {
27
77
  const now = this.clock.now() / 1000; // fractional seconds for continuous refill
28
78
  this.pruneExpiredBuckets(now);
29
79
  let bucket = this.buckets.get(key);
30
80
  if (!bucket) {
81
+ if (this.buckets.size >= this.maxBuckets) {
82
+ const { evicted, retryAfterSeconds } = this.evictRefilledBucket(now);
83
+ if (!evicted) {
84
+ // No safe eviction candidate: admitting a bucket for this never-seen key would
85
+ // grow the map past maxBuckets. Deny it instead of allocating state (#634).
86
+ return {
87
+ isAllowed: false,
88
+ retryAfterSeconds,
89
+ limit: this.capacity,
90
+ remaining: 0,
91
+ };
92
+ }
93
+ }
31
94
  bucket = {
32
95
  tokens: this.capacity,
33
96
  lastRefillTimestamp: now,
@@ -37,6 +100,8 @@ export class TokenBucketRateLimiter {
37
100
  const elapsed = Math.max(0, now - bucket.lastRefillTimestamp);
38
101
  bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRatePerSecond);
39
102
  bucket.lastRefillTimestamp = now;
103
+ // Re-insert to maintain LRU access order
104
+ this.buckets.delete(key);
40
105
  }
41
106
  if (bucket.tokens >= 1) {
42
107
  bucket.tokens -= 1;
@@ -59,5 +124,6 @@ export class TokenBucketRateLimiter {
59
124
  }
60
125
  reset() {
61
126
  this.buckets.clear();
127
+ this.lastPruneTimestamp = 0;
62
128
  }
63
129
  }
@@ -1,3 +1,5 @@
1
+ import type { NewUserInput } from '../auth/user.repository.js';
2
+ import type { PageRequest } from './types.js';
1
3
  import type { Scope } from './permissions.js';
2
4
  /** One invitation row. `tokenHash` is deliberately absent: nothing above the storage
3
5
  * boundary ever needs it, and an administration listing must not carry credential
@@ -10,7 +12,7 @@ export interface InvitationRecord {
10
12
  invitedBy: string;
11
13
  expiresAt: number;
12
14
  createdAt: number;
13
- /** Non-null once redeemed. Single-use is enforced by {@link IInvitationRepository.markUsed}. */
15
+ /** Non-null once redeemed. Single-use is enforced by {@link IInvitationRepository.redeem}. */
14
16
  usedAt: number | null;
15
17
  }
16
18
  export interface NewInvitationInput {
@@ -29,6 +31,15 @@ export interface ValidatedInvitation {
29
31
  scope: Scope;
30
32
  invitedBy: string;
31
33
  }
34
+ /** What {@link IInvitationRepository.redeem} writes in one transaction. */
35
+ export interface InvitationRedemption {
36
+ invitationId: string;
37
+ user: NewUserInput;
38
+ assignmentId: string;
39
+ roleId: string;
40
+ scope: Scope;
41
+ usedAt: number;
42
+ }
32
43
  /**
33
44
  * Storage contract for onboarding invitations.
34
45
  *
@@ -37,9 +48,11 @@ export interface ValidatedInvitation {
37
48
  * exactly one shape for it.
38
49
  */
39
50
  export interface IInvitationRepository {
40
- /** Consumes every pending invitation for an email before a new one is issued, so at
41
- * most one live token per address exists at any time. */
42
- invalidatePending(email: string, nowTimestamp: number): Promise<void>;
51
+ /** Deletes every unredeemed invitation for an email before a new one is issued, so at
52
+ * most one live token per address exists at any time. They are removed, not marked
53
+ * used: `used_at` means "redeemed", and a superseded row must never be reported as
54
+ * accepted. */
55
+ supersedePending(email: string): Promise<void>;
43
56
  /** Stores a new invitation. Only the hash is persisted, never the plaintext.
44
57
  * Returns the new invitation id. */
45
58
  create(input: NewInvitationInput): Promise<string>;
@@ -47,15 +60,20 @@ export interface IInvitationRepository {
47
60
  * expired, already used and unknown are deliberately indistinguishable. */
48
61
  findValidByHash(tokenHash: string, nowTimestamp: number): Promise<ValidatedInvitation | null>;
49
62
  /**
50
- * Consumes an invitation. Returns TRUE only when THIS call performed the transition
51
- * (`used_at IS NULL` at write time), which is what makes redemption single-use under
52
- * concurrency. A second concurrent redeem gets false and must be refused.
63
+ * Redeems an invitation: creates the account, writes its (role, scope) assignment and
64
+ * consumes the invitation in ONE atomic write. Either all three land or none does, so a
65
+ * failure can neither burn the invitation nor leave an account without its role.
66
+ *
67
+ * Returns TRUE only when THIS call performed the transition (`used_at IS NULL` at write
68
+ * time), which is what makes redemption single-use under concurrency; FALSE means the
69
+ * invitation was consumed or revoked meanwhile and nothing was written. Throws when the
70
+ * email is already registered (UNIQUE), writing nothing.
53
71
  */
54
- markUsed(invitationId: string, nowTimestamp: number): Promise<boolean>;
72
+ redeem(input: InvitationRedemption): Promise<boolean>;
55
73
  /** One invitation by id, regardless of state. Null when absent. */
56
74
  findById(invitationId: string): Promise<InvitationRecord | null>;
57
- /** Every invitation, newest first. Administration tables are small by nature. */
58
- listAll(): Promise<InvitationRecord[]>;
75
+ /** One page of invitations, newest first. */
76
+ list(page: PageRequest): Promise<InvitationRecord[]>;
59
77
  /** Replaces the token and expiry of an existing PENDING-or-EXPIRED invitation,
60
78
  * preserving its (role, scope, email) pre-assignment. Returns false when the row is
61
79
  * absent or already used — a consumed invitation is never re-armed. */
@@ -1 +1 @@
1
- {"version":3,"file":"invitation.repository.d.ts","sourceRoot":"","sources":["../../src/rbac/invitation.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAE7C;;oEAEoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,gGAAgG;IAChG,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,0FAA0F;AAC1F,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC;8DAC0D;IAC1D,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAErE;yCACqC;IACrC,MAAM,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAElD;gFAC4E;IAC5E,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAA;IAE7F;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAEtE,mEAAmE;IACnE,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAEhE,iFAAiF;IACjF,OAAO,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAA;IAEtC;;4EAEwE;IACxE,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAExF,iFAAiF;IACjF,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAC/C"}
1
+ {"version":3,"file":"invitation.repository.d.ts","sourceRoot":"","sources":["../../src/rbac/invitation.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AAE7C;;oEAEoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,8FAA8F;IAC9F,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,0FAA0F;AAC1F,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,2EAA2E;AAC3E,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,YAAY,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;oBAGgB;IAChB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE9C;yCACqC;IACrC,MAAM,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAElD;gFAC4E;IAC5E,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAA;IAE7F;;;;;;;;;OASG;IACH,MAAM,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAErD,mEAAmE;IACnE,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAEhE,6CAA6C;IAC7C,IAAI,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAA;IAEpD;;4EAEwE;IACxE,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAExF,iFAAiF;IACjF,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAC/C"}
@@ -41,6 +41,22 @@ export interface EffectivePermissions {
41
41
  /** Permissions held at a specific seed slug, keyed by that slug. */
42
42
  byScope: ReadonlyMap<Scope, ReadonlySet<Permission>>;
43
43
  }
44
+ /** One page of an administration listing. `limit` is at most {@link MAX_PAGE_SIZE}. */
45
+ export interface PageRequest {
46
+ limit: number;
47
+ offset: number;
48
+ }
49
+ /** Largest page the administration listings serve. Keeps every `IN (...)` fan-out under D1's bound-parameter cap. */
50
+ export declare const MAX_PAGE_SIZE = 100;
51
+ /**
52
+ * Outcome of a write that must never remove the platform's last active global
53
+ * administrator. The repository enforces the invariant inside the write itself (one
54
+ * conditional statement), so two concurrent requests cannot both pass a stale count.
55
+ * - `applied`: the write happened.
56
+ * - `not-found`: the target does not exist (or is an immutable system role); nothing changed.
57
+ * - `last-global-admin`: refused because it would leave no other active global administrator.
58
+ */
59
+ export type AdminGuardedResult = 'applied' | 'not-found' | 'last-global-admin';
44
60
  /** Storage contract for roles and their permission bundles. */
45
61
  export interface IRoleRepository {
46
62
  /** Retrieves a role with its permissions, or null when it does not exist. */
@@ -54,17 +70,19 @@ export interface IRoleRepository {
54
70
  /**
55
71
  * Replaces a role's name, description and full permission set atomically.
56
72
  *
57
- * Returns false — changing NOTHING, permissions included — when the role does not
58
- * exist or is a system role. System roles are seeded by migration and are immutable
59
- * in both halves of the write, which is the guarantee callers rely on to keep
60
- * `SuperAdmin` intact.
73
+ * `not-found` — changing NOTHING, permissions included — when the role does not exist or
74
+ * is a system role. System roles are seeded by migration and are immutable in both halves
75
+ * of the write, which is the guarantee callers rely on to keep `SuperAdmin` intact.
76
+ * `last-global-admin` when the new set drops `manage_users` from a role that carries the
77
+ * last active global administrator; nothing is written then either.
61
78
  */
62
- update(roleId: string, input: NewRoleInput): Promise<boolean>;
79
+ update(roleId: string, input: NewRoleInput): Promise<AdminGuardedResult>;
63
80
  /**
64
81
  * Deletes a non-system role and, by cascade, its permissions and assignments.
65
- * Returns false when the role does not exist or is a system role.
82
+ * `not-found` when the role does not exist or is a system role; `last-global-admin` when
83
+ * the role carries the last active global administrator.
66
84
  */
67
- delete(roleId: string): Promise<boolean>;
85
+ delete(roleId: string): Promise<AdminGuardedResult>;
68
86
  }
69
87
  /** Storage contract for (user, role, scope) assignments. */
70
88
  export interface IRoleAssignmentRepository {
@@ -80,39 +98,33 @@ export interface IRoleAssignmentRepository {
80
98
  listByRole(roleId: string): Promise<PermissionAssignment[]>;
81
99
  /** Creates an assignment. Returns its id; an existing identical triple is a no-op. */
82
100
  create(input: NewAssignmentInput): Promise<string>;
83
- /** Removes one assignment. Returns false when it did not exist. */
84
- delete(assignmentId: string): Promise<boolean>;
85
101
  /**
86
- * Counts accounts holding `manage_users` at {@link GLOBAL_SCOPE} and still active.
87
- * Backs the last-SuperAdmin guardrail consumed by a later sprint.
102
+ * Removes one assignment. `not-found` when it did not exist; `last-global-admin` when it is
103
+ * the global `manage_users` assignment of the last active global administrator.
104
+ */
105
+ delete(assignmentId: string): Promise<AdminGuardedResult>;
106
+ /**
107
+ * Read-only twin of the {@link delete} guard: would removing this assignment leave no
108
+ * other active global administrator? ADVISORY — it only lets a caller that refuses for
109
+ * another reason (the Developer account) report the more fundamental one; it is never a
110
+ * substitute for the guard inside {@link delete}.
88
111
  */
89
- countActiveGlobalAdmins(): Promise<number>;
112
+ isLastGlobalAdminAssignment(assignmentId: string): Promise<boolean>;
90
113
  /** One assignment by id, decay filter NOT applied. Null when absent. */
91
114
  findById(assignmentId: string): Promise<PermissionAssignment | null>;
92
115
  /**
93
- * Every assignment in the system, decay filter NOT applied.
116
+ * Every assignment of the given users, decay filter NOT applied, in ONE round trip.
94
117
  *
95
- * Exists so the account-list endpoint can resolve each account's scopes in ONE round
96
- * trip instead of one query per account. Administration tables are small by nature;
97
- * content never flows through here.
118
+ * Exists so the account-list endpoint can resolve the scopes of one page of accounts
119
+ * without a query per account. Callers pass at most {@link MAX_PAGE_SIZE} ids (D1 caps
120
+ * bound parameters per statement); an empty list returns an empty list.
98
121
  */
99
- listAll(): Promise<PermissionAssignment[]>;
122
+ listForUsers(userIds: readonly string[]): Promise<PermissionAssignment[]>;
100
123
  /**
101
124
  * Every assignment of one user, decay filter NOT applied — administration screens must
102
125
  * see (and be able to remove) a row whose seed is currently deleted, which
103
126
  * {@link listActiveForUser} deliberately hides.
104
127
  */
105
128
  listAllForUser(userId: string): Promise<PermissionAssignment[]>;
106
- /**
107
- * {@link countActiveGlobalAdmins} ignoring one user. `0` means that user is the last
108
- * account able to administer the platform, and any operation revoking their authority
109
- * must be refused.
110
- */
111
- countActiveGlobalAdminsExcludingUser(userId: string): Promise<number>;
112
- /**
113
- * {@link countActiveGlobalAdmins} ignoring every assignment that goes through one role.
114
- * `0` means mutating that role would strip the platform of its last administrator.
115
- */
116
- countActiveGlobalAdminsExcludingRole(roleId: string): Promise<number>;
117
129
  }
118
130
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/rbac/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/rbac/types.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
- export {};
3
+ /** Largest page the administration listings serve. Keeps every `IN (...)` fan-out under D1's bound-parameter cap. */
4
+ export const MAX_PAGE_SIZE = 100;
@@ -10,9 +10,9 @@ import { MANIFEST_VERSION } from './manifest.types.js';
10
10
  * Fills in ids for branches authored without one, in declaration order.
11
11
  *
12
12
  * These ids are AUTHORING-LOCAL and non-authoritative — they exist so `validateSeedDefinitions`,
13
- * which fatals on a missing id, has a complete `Seed` to check. At plan/apply time the stored id
14
- * wins: `normalizeCandidate()` in the API's MCP slice matches by alias against the stored
15
- * definition before minting anything. Never persist an id minted here as if it were stable.
13
+ * which fatals on a missing id, has a complete `Seed` to check. NEVER send this output to plan/apply:
14
+ * a minted id looks authoritative to the server and shadows the stored one. The CLI sends the raw
15
+ * manifest seeds instead, so `resolveCandidateIds()` matches stored ids by alias.
16
16
  */
17
17
  function withBranchIds(branches) {
18
18
  const accumulated = [];
@@ -30,15 +30,16 @@ export interface ISearchRepository {
30
30
  * Executes a UNION ALL full-text search across all FTS-enabled seed tables.
31
31
  * Returns at most options.limit + 1 rows so the caller can detect hasMore
32
32
  * without a separate count query for the cursor case.
33
- * Implementations must propagate the EMPTY_QUERY error thrown by
34
- * buildFtsQuery so the route handler can return an empty result set rather
35
- * than a 500.
33
+ * A query with no usable terms (EMPTY_QUERY from buildFtsQuery) must resolve
34
+ * to an empty array rather than throw, so the route handler never returns a
35
+ * 500 for it.
36
36
  */
37
37
  search(options: SearchQueryOptions, seeds: Seed[]): Promise<SearchResultRow[]>;
38
38
  /**
39
39
  * Runs the count variant of the FTS query to support the `total` field in
40
40
  * the search response. Called in parallel with search() by the route handler
41
- * with the same filter inputs but without limit/cursor.
41
+ * with the same filter inputs but without limit/cursor. An EMPTY_QUERY
42
+ * resolves to `{ total: 0 }` rather than throwing.
42
43
  */
43
44
  count(options: Omit<SearchQueryOptions, 'limit' | 'cursor'>, seeds: Seed[]): Promise<SearchCountResult>;
44
45
  }
@@ -1 +1 @@
1
- {"version":3,"file":"search.repository.d.ts","sourceRoot":"","sources":["../../src/search/search.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,MAAM,CAAC,OAAO,EAAE,kBAAkB,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAA;IAE9E;;;;OAIG;IACH,KAAK,CACH,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,OAAO,GAAG,QAAQ,CAAC,EACrD,KAAK,EAAE,IAAI,EAAE,GACZ,OAAO,CAAC,iBAAiB,CAAC,CAAA;CAC9B"}
1
+ {"version":3,"file":"search.repository.d.ts","sourceRoot":"","sources":["../../src/search/search.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,MAAM,CAAC,OAAO,EAAE,kBAAkB,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAA;IAE9E;;;;;OAKG;IACH,KAAK,CACH,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,OAAO,GAAG,QAAQ,CAAC,EACrD,KAAK,EAAE,IAAI,EAAE,GACZ,OAAO,CAAC,iBAAiB,CAAC,CAAA;CAC9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"vector-extractor.d.ts","sourceRoot":"","sources":["../../src/search/vector-extractor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAG9C;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,GAAG,IAAI,CAc1F"}
1
+ {"version":3,"file":"vector-extractor.d.ts","sourceRoot":"","sources":["../../src/search/vector-extractor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAK9C;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,GAAG,IAAI,CA4B1F"}
@@ -1,6 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
- import { indexableSearchBranches } from '../engine/ddl.js';
3
+ import { indexableSearchBranches } from '../engine/ddl/ddl.js';
4
+ import { isLocaleDictionary, isLocalizedBranch } from '../engine/localization/localization.js';
5
+ import { extractRichtextText } from '../engine/validation/richtext-sanitizer.js';
4
6
  /**
5
7
  * Extracts and concatenates text from all public indexable text/richtext branches of a seed.
6
8
  * Enforces privacy policies by utilizing indexableSearchBranches.
@@ -16,9 +18,28 @@ export function extractIndexableText(seed, entry) {
16
18
  const texts = [];
17
19
  for (const branch of branches) {
18
20
  const val = entry[branch.alias];
19
- if (val && typeof val === 'string') {
21
+ if (isLocalizedBranch(branch) && isLocaleDictionary(val)) {
22
+ // Every language is indexed, so a query matches regardless of the language it is typed in.
23
+ for (const localeValue of Object.values(val)) {
24
+ if (typeof localeValue === 'string' && localeValue) {
25
+ texts.push(localeValue);
26
+ }
27
+ else if (branch.type === 'richtext') {
28
+ const text = extractRichtextText(localeValue);
29
+ if (text)
30
+ texts.push(text);
31
+ }
32
+ }
33
+ }
34
+ else if (typeof val === 'string' && val) {
20
35
  texts.push(val);
21
36
  }
37
+ else if (branch.type === 'richtext') {
38
+ // Deserialized richtext is always a TipTap doc object (or v1 envelope), never a string.
39
+ const text = extractRichtextText(val);
40
+ if (text)
41
+ texts.push(text);
42
+ }
22
43
  }
23
44
  const combined = texts.join(' ').trim();
24
45
  return combined.length > 0 ? combined : null;
@@ -4,10 +4,11 @@ export interface IVectorRepository {
4
4
  saveVector(seed: Seed, entryId: string, vector: Float32Array): Promise<void>;
5
5
  /** Removes the embedding vector (used when unpublished/deleted) */
6
6
  deleteVector(seed: Seed, entryId: string): Promise<void>;
7
- /** Retrieves all vectors for a given seed to compile to R2 */
7
+ /** Retrieves all vectors (with entry title) for a given seed to compile to R2 */
8
8
  getAllVectors(seed: Seed): Promise<{
9
9
  entryId: string;
10
10
  vector: Float32Array;
11
+ title: string;
11
12
  }[]>;
12
13
  }
13
14
  //# sourceMappingURL=vector.repository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vector.repository.d.ts","sourceRoot":"","sources":["../../src/search/vector.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,MAAM,WAAW,iBAAiB;IAChC,yDAAyD;IACzD,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE5E,mEAAmE;IACnE,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAExD,8DAA8D;IAC9D,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,YAAY,CAAA;KAAE,EAAE,CAAC,CAAA;CAChF"}
1
+ {"version":3,"file":"vector.repository.d.ts","sourceRoot":"","sources":["../../src/search/vector.repository.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,MAAM,WAAW,iBAAiB;IAChC,yDAAyD;IACzD,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE5E,mEAAmE;IACnE,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAExD,iFAAiF;IACjF,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,YAAY,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAA;CAC/F"}
@@ -1,8 +1,10 @@
1
1
  export interface ITimeTrapTokenRepository {
2
- /** Checks if a time-trap token hash has already been consumed. */
2
+ /** Cheap, non-atomic pre-check used only to fail fast before expensive validation. */
3
3
  isTokenUsed(tokenHash: string): Promise<boolean>;
4
- /** Marks a time-trap token hash as consumed with an expiration timestamp. */
5
- markTokenUsed(tokenHash: string, usedAt: number, expiresAt: number): Promise<void>;
4
+ /** Atomically claims a token hash as consumed. Returns false if it was already claimed. */
5
+ claimToken(tokenHash: string, usedAt: number, expiresAt: number): Promise<boolean>;
6
+ /** Releases a previously claimed token hash, allowing retry after a failed content creation. */
7
+ releaseToken(tokenHash: string): Promise<void>;
6
8
  /** Cleans up expired token entries. */
7
9
  cleanup(nowSeconds: number): Promise<void>;
8
10
  }
@@ -1 +1 @@
1
- {"version":3,"file":"time-trap-token.repository.d.ts","sourceRoot":"","sources":["../../src/security/time-trap-token.repository.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,wBAAwB;IACvC,kEAAkE;IAClE,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAChD,6EAA6E;IAC7E,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClF,uCAAuC;IACvC,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3C"}
1
+ {"version":3,"file":"time-trap-token.repository.d.ts","sourceRoot":"","sources":["../../src/security/time-trap-token.repository.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,wBAAwB;IACvC,sFAAsF;IACtF,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAChD,2FAA2F;IAC3F,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAClF,gGAAgG;IAChG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9C,uCAAuC;IACvC,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3C"}
@@ -6,6 +6,14 @@ export interface SiteSettings {
6
6
  companyName: string | null;
7
7
  companyWebsite: string | null;
8
8
  companyAbbreviation: string | null;
9
+ /**
10
+ * Content locales for field-level localization, in display order. `null` = never configured
11
+ * (resolveLocaleConfig then falls back to `[defaultLanguage]`). Distinct from `defaultLanguage`,
12
+ * which is the dashboard UI language.
13
+ */
14
+ locales: string[] | null;
15
+ /** Default content locale. `null` = never configured. */
16
+ defaultLocale: string | null;
9
17
  }
10
18
  export interface ISiteSettingsRepository {
11
19
  /** Returns all stored settings, applying sensible defaults for missing keys. */
@@ -1 +1 @@
1
- {"version":3,"file":"site-settings.repository.d.ts","sourceRoot":"","sources":["../../src/settings/site-settings.repository.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC;AAED,MAAM,WAAW,uBAAuB;IACtC,gFAAgF;IAChF,MAAM,IAAI,OAAO,CAAC,YAAY,CAAC,CAAA;IAC/B,kFAAkF;IAClF,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACtD"}
1
+ {"version":3,"file":"site-settings.repository.d.ts","sourceRoot":"","sources":["../../src/settings/site-settings.repository.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC;;;;OAIG;IACH,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IACxB,yDAAyD;IACzD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAED,MAAM,WAAW,uBAAuB;IACtC,gFAAgF;IAChF,MAAM,IAAI,OAAO,CAAC,YAAY,CAAC,CAAA;IAC/B,kFAAkF;IAClF,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACtD"}
@@ -1 +1 @@
1
- {"version":3,"file":"flat-seed.d.ts","sourceRoot":"","sources":["../../src/transfer/flat-seed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAc,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAClE,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAqB9E,4FAA4F;AAC5F,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAEpD;AAED,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,cAAc,GACrB,mBAAmB,CAWrB"}
1
+ {"version":3,"file":"flat-seed.d.ts","sourceRoot":"","sources":["../../src/transfer/flat-seed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAc,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAGlE,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAuB9E,4FAA4F;AAC5F,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAEpD;AAED,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,cAAc,GACrB,mBAAmB,CAWrB"}
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
+ import { isAssetListBranch } from '../engine/validation/file-branch.js';
4
+ import { isLocalizedBranch } from '../engine/localization/localization.js';
3
5
  /**
4
6
  * Branch types whose value is a collection or a nested document, never a scalar cell.
5
7
  * A CSV cell cannot carry them without inventing a flattening convention that the
@@ -10,12 +12,15 @@ const NON_FLAT_BRANCH_TYPES = new Set([
10
12
  'repeater',
11
13
  'tags',
12
14
  'json',
15
+ 'richtext',
13
16
  ]);
14
17
  /** True for a branch that cannot be represented as a single scalar CSV cell. */
15
18
  function isNonFlat(branch) {
19
+ if (isLocalizedBranch(branch))
20
+ return true;
16
21
  // A `file` branch is a single URL string — flat — unless it is an asset list.
17
22
  if (branch.type === 'file')
18
- return branch.multiple === true;
23
+ return isAssetListBranch(branch);
19
24
  return NON_FLAT_BRANCH_TYPES.has(branch.type);
20
25
  }
21
26
  /** Every branch of `seed` that makes it non-flat, in declaration order. Empty when flat. */
@@ -1 +1 @@
1
- {"version":3,"file":"row-mapping.d.ts","sourceRoot":"","sources":["../../src/transfer/row-mapping.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEzF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,+DAAgE,CAAA;AAclG;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAMlD;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAO1F;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,eAAe,CAgBhF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,CAYrE"}
1
+ {"version":3,"file":"row-mapping.d.ts","sourceRoot":"","sources":["../../src/transfer/row-mapping.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AAE9C,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEzF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,+DAAgE,CAAA;AAclG;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAMlD;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAU1F;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,eAAe,CAiBhF;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,aAAa,CAarE"}
@@ -44,6 +44,9 @@ export function toCsvCells(record, columns) {
44
44
  return null;
45
45
  if (typeof value === 'boolean')
46
46
  return value ? 'true' : 'false';
47
+ if (typeof value === 'object') {
48
+ throw new TypeError(`Cannot serialize non-scalar value for column "${column}" to CSV cell`);
49
+ }
47
50
  return String(value);
48
51
  });
49
52
  }
@@ -60,8 +63,10 @@ export function fromCsvCells(columns, cells) {
60
63
  message: `Expected ${columns.length} columns, received ${cells.length}`,
61
64
  };
62
65
  }
63
- const record = {};
66
+ const record = Object.create(null);
64
67
  for (const [index, column] of columns.entries()) {
68
+ if (column === '__proto__' || column === 'constructor' || column === 'prototype')
69
+ continue;
65
70
  const cell = cells[index] ?? '';
66
71
  if (cell === '')
67
72
  continue;
@@ -76,8 +81,10 @@ export function fromCsvCells(columns, cells) {
76
81
  * `data` straight to `validateAndSanitizeSeedPayload` without re-filtering.
77
82
  */
78
83
  export function toImportPayload(record) {
79
- const data = {};
84
+ const data = Object.create(null);
80
85
  for (const [key, value] of Object.entries(record)) {
86
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
87
+ continue;
81
88
  if (IMPORT_REJECTED_COLUMNS.has(key))
82
89
  continue;
83
90
  if (key === 'slug' || key === 'status')
package/package.json CHANGED
@@ -1,18 +1,23 @@
1
1
  {
2
2
  "name": "@beechcms/core",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "files": [
9
- "dist"
9
+ "dist",
10
+ "!dist/.tsbuildinfo"
10
11
  ],
11
12
  "exports": {
12
13
  ".": {
13
14
  "import": "./dist/index.js",
14
15
  "types": "./dist/index.d.ts"
15
16
  },
17
+ "./schema": {
18
+ "import": "./dist/schema/index.js",
19
+ "types": "./dist/schema/index.d.ts"
20
+ },
16
21
  "./richtext-render": {
17
22
  "import": "./dist/content/richtext/richtext-render.js",
18
23
  "types": "./dist/content/richtext/richtext-render.d.ts"
@@ -40,7 +45,6 @@
40
45
  "@tiptap/extension-table": "^3.31.3",
41
46
  "@tiptap/extension-text-align": "^3.31.3",
42
47
  "@tiptap/extension-text-style": "^3.31.3",
43
- "@tiptap/extension-underline": "^3.31.3",
44
48
  "@tiptap/html": "^3.31.3",
45
49
  "@tiptap/starter-kit": "^3.31.3",
46
50
  "katex": "^0.16.11",
@@ -53,6 +57,7 @@
53
57
  "lint": "eslint .",
54
58
  "type-check": "tsc --noEmit",
55
59
  "test": "vitest run",
60
+ "test:unit": "vitest run",
56
61
  "test:coverage": "vitest run --coverage"
57
62
  }
58
63
  }