@ogygie/core 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/core",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -456,3 +456,45 @@ describe("manifest — packages du produit (spec manifest-packages)", () => {
456
456
  expect(parsed.success).toBe(false);
457
457
  });
458
458
  });
459
+
460
+ describe("manifest — bloc tenancy (spec ogygie-standalone-repo v3)", () => {
461
+ const socle = featureNamed("espaces-et-comptes");
462
+
463
+ it("un bloc tenancy valide est accepté, rawDbAllowed a un défaut vide", () => {
464
+ const parsed = OgygieManifestSchema.safeParse(
465
+ manifestWithPackages(undefined, [socle]),
466
+ );
467
+ expect(parsed.success).toBe(true);
468
+ const withTenancy = OgygieManifestSchema.safeParse({
469
+ ...manifestWithPackages(undefined, [socle]),
470
+ tenancy: { column: "workspace_id" },
471
+ });
472
+ expect(withTenancy.success && withTenancy.data.tenancy?.rawDbAllowed).toEqual([]);
473
+ });
474
+
475
+ it("le socle peut nommer les features autorisées à la base brute", () => {
476
+ const parsed = OgygieManifestSchema.safeParse({
477
+ ...manifestWithPackages(undefined, [socle]),
478
+ tenancy: { column: "workspace_id", rawDbAllowed: ["espaces-et-comptes"] },
479
+ });
480
+ expect(parsed.success).toBe(true);
481
+ });
482
+
483
+ it("MUTATION : un socle nommant une feature INEXISTANTE est refusé", () => {
484
+ const parsed = OgygieManifestSchema.safeParse({
485
+ ...manifestWithPackages(undefined, [socle]),
486
+ tenancy: { column: "workspace_id", rawDbAllowed: ["fantome"] },
487
+ });
488
+ expect(parsed.success).toBe(false);
489
+ const issue = parsed.success ? undefined : parsed.error.issues[0];
490
+ expect(issue?.message).toContain("matches no declared feature");
491
+ });
492
+
493
+ it("MUTATION : une colonne tenant vide est refusée — elle ne garderait rien", () => {
494
+ const parsed = OgygieManifestSchema.safeParse({
495
+ ...manifestWithPackages(undefined, [socle]),
496
+ tenancy: { column: "" },
497
+ });
498
+ expect(parsed.success).toBe(false);
499
+ });
500
+ });
@@ -64,9 +64,33 @@ export const HttpRouteSchema = z.object({
64
64
  * `shared` is a literal `true`: a table is either shared (and says so) or
65
65
  * tenant-scoped (and stays a plain string) — no `shared: false` third state.
66
66
  */
67
+ /**
68
+ * WHICH hole in the tenancy rail, because the two do not carry the same risk
69
+ * (D8):
70
+ * - `reference` — universal knowledge loaded by an extraction script, READ
71
+ * ONLY for the API. What an ABSENT key means, which is also what
72
+ * `shared: true` historically meant — read it through `sharedKindOf()`
73
+ * rather than defaulting in the schema, so the manifest a project wrote is
74
+ * the manifest an agent reads.
75
+ * - `ownerless` — data the API itself produces, owned by nobody because it is
76
+ * born before any account. Its feature WRITES it, and since no tenant guards
77
+ * it, an unguessable secret must: its rows are read by token, never by an
78
+ * enumerable id. No gate can check that, so the feature's spec must say
79
+ * where the secret lives.
80
+ *
81
+ * A security fact belongs on the map, not in a comment — hence a declared key
82
+ * rather than a convention.
83
+ */
84
+ export const SharedTableKindSchema = z.enum(["reference", "ownerless"]);
85
+ export type SharedTableKind = z.infer<typeof SharedTableKindSchema>;
86
+
67
87
  export const DbTableDeclarationSchema = z.union([
68
88
  z.string(),
69
- z.object({ name: z.string(), shared: z.literal(true) }),
89
+ z.object({
90
+ name: z.string(),
91
+ shared: z.literal(true),
92
+ sharedKind: SharedTableKindSchema.optional(),
93
+ }),
70
94
  ]);
71
95
  export type DbTableDeclaration = z.infer<typeof DbTableDeclarationSchema>;
72
96
 
@@ -183,6 +207,34 @@ export const PackageManifestSchema = z.object({
183
207
  });
184
208
  export type PackageManifest = z.infer<typeof PackageManifestSchema>;
185
209
 
210
+ /**
211
+ * The two product values the framework's own gates need to do their job.
212
+ *
213
+ * They used to live as constants INSIDE the gate scripts, which is what kept
214
+ * those scripts out of governance: a governed file is identical in every
215
+ * Ogygie project, and these are not. Declared here, the scripts become
216
+ * identical everywhere and can finally be governed — so a corrected gate
217
+ * reaches every project through `ogygie upgrade` instead of waiting for each
218
+ * one to recopy it (spec ogygie-standalone-repo v3).
219
+ *
220
+ * Absent, the checks that depend on them simply do not run: a project without
221
+ * tenancy has nothing to guard, and saying so explicitly beats guarding
222
+ * nothing while pretending otherwise.
223
+ */
224
+ export const TenancyManifestSchema = z.object({
225
+ column: z
226
+ .string()
227
+ .min(1)
228
+ .describe("SQL name of the tenant column, e.g. workspace_id — the one AGENTS.product.md announces"),
229
+ rawDbAllowed: z
230
+ .array(FeatureNameSchema)
231
+ .default([])
232
+ .describe(
233
+ "Features allowed to reach the raw database: the tenancy socle, which BUILDS the contexts and therefore predates them. Every other feature goes through tenantDb",
234
+ ),
235
+ });
236
+ export type TenancyManifest = z.infer<typeof TenancyManifestSchema>;
237
+
186
238
  export const OgygieManifestSchema = z.object({
187
239
  version: z.literal(1),
188
240
  product: z.object({
@@ -213,6 +265,9 @@ export const OgygieManifestSchema = z.object({
213
265
  }),
214
266
  ),
215
267
  features: z.array(FeatureManifestSchema),
268
+ tenancy: TenancyManifestSchema.optional().describe(
269
+ "Product values the framework gates need — absent, the tenancy checks do not run",
270
+ ),
216
271
  packages: z
217
272
  .array(PackageManifestSchema)
218
273
  .optional()
@@ -280,6 +335,18 @@ export const OgygieManifestSchema = z.object({
280
335
  checkPackageDependencies(packageEntry.dependsOn, ["packages", index, "dependsOn"]);
281
336
  });
282
337
 
338
+ // A socle that names no existing feature guards nothing, and nobody would
339
+ // notice: the lint would simply allow one fewer folder.
340
+ (manifest.tenancy?.rawDbAllowed ?? []).forEach((featureName, index) => {
341
+ if (!featureNames.has(featureName)) {
342
+ context.addIssue({
343
+ code: "custom",
344
+ path: ["tenancy", "rawDbAllowed", index],
345
+ message: `"${featureName}" matches no declared feature`,
346
+ });
347
+ }
348
+ });
349
+
283
350
  // Vocabulary coherence (B2): one concept = one term. A canonical declared
284
351
  // twice, an alias claimed by two entries, or an alias that IS someone's
285
352
  // canonical would make the gate's suggested fix ambiguous — refused here,
@@ -7,15 +7,7 @@
7
7
  import { describe, expect, it } from "vitest";
8
8
  import type { FeatureManifest } from "./manifest.schema.js";
9
9
  import { FeatureManifestSchema } from "./manifest.schema.js";
10
- import {
11
- dbTableNamesOf,
12
- findTenantColumnsInSharedTables,
13
- mayUseRawDb,
14
- ownsOnlySharedTables,
15
- sharedTableNamesOf,
16
- splitDbSchemaByTable,
17
- tenantTableNamesOf,
18
- } from "./shared-tables.js";
10
+ import { dbTableNamesOf, findTenantColumnsInSharedTables, mayUseRawDb, ownsOnlySharedTables, sharedKindOf, sharedTableNamesOf, splitDbSchemaByTable, tenantTableNamesOf } from "./shared-tables.js";
19
11
 
20
12
  const SOCLE = new Set(["accounts"]);
21
13
 
@@ -134,3 +126,25 @@ export const labels = pgTable("labels", {
134
126
  ).toEqual([{ table: "labels", tenantColumn: "org_id" }]);
135
127
  });
136
128
  });
129
+
130
+ describe("sharedKindOf — quelle brèche dans le rail tenant (D8)", () => {
131
+ it("une table tenant n'a AUCUNE nature : ce n'est pas une brèche", () => {
132
+ expect(sharedKindOf("orders")).toBeNull();
133
+ });
134
+
135
+ it("une table partagée sans `sharedKind` vaut `reference` — le sens historique", () => {
136
+ expect(sharedKindOf({ name: "currency_rates", shared: true })).toBe("reference");
137
+ });
138
+
139
+ it("une table `ownerless` est rendue telle quelle : sa feature l'écrit", () => {
140
+ expect(sharedKindOf({ name: "public_drafts", shared: true, sharedKind: "ownerless" })).toBe(
141
+ "ownerless",
142
+ );
143
+ });
144
+
145
+ it("une table `reference` explicite reste `reference`", () => {
146
+ expect(sharedKindOf({ name: "postal_codes", shared: true, sharedKind: "reference" })).toBe(
147
+ "reference",
148
+ );
149
+ });
150
+ });
@@ -19,7 +19,7 @@
19
19
  * they return. The module is GENERIC infrastructure (D10 amended): it speaks
20
20
  * "tenant", never a word of the product's domain.
21
21
  */
22
- import type { DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
22
+ import type { SharedTableKind, DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
23
23
 
24
24
  /** Name of a table however it was declared (bare string or object form). */
25
25
  export function dbTableNameOf(declaration: DbTableDeclaration): string {
@@ -111,3 +111,14 @@ export function findTenantColumnsInSharedTables(
111
111
  }
112
112
  return findings;
113
113
  }
114
+
115
+ /**
116
+ * The nature of a shared table: what the declaration says, or `reference` when
117
+ * it says nothing — the historical meaning of `shared: true`, kept so older
118
+ * manifests keep meaning what they meant (AGENTS.md §Multi-tenant rule 4).
119
+ * A tenant-scoped table has no nature: it is not a hole in the rail.
120
+ */
121
+ export function sharedKindOf(declaration: DbTableDeclaration): SharedTableKind | null {
122
+ if (typeof declaration === "string") return null;
123
+ return declaration.sharedKind ?? "reference";
124
+ }