@minion-stack/db 0.3.0 → 0.4.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 (41) hide show
  1. package/dist/crypto.d.ts +19 -0
  2. package/dist/crypto.d.ts.map +1 -0
  3. package/dist/crypto.js +65 -0
  4. package/dist/crypto.js.map +1 -0
  5. package/dist/crypto.test.d.ts +2 -0
  6. package/dist/crypto.test.d.ts.map +1 -0
  7. package/dist/crypto.test.js +23 -0
  8. package/dist/crypto.test.js.map +1 -0
  9. package/dist/pg/crypto.d.ts +1 -7
  10. package/dist/pg/crypto.d.ts.map +1 -1
  11. package/dist/pg/crypto.js +4 -41
  12. package/dist/pg/crypto.js.map +1 -1
  13. package/dist/pg/schema/gateway.d.ts +262 -0
  14. package/dist/pg/schema/gateway.d.ts.map +1 -0
  15. package/dist/pg/schema/gateway.js +36 -0
  16. package/dist/pg/schema/gateway.js.map +1 -0
  17. package/dist/pg/schema/index.d.ts +2 -0
  18. package/dist/pg/schema/index.d.ts.map +1 -1
  19. package/dist/pg/schema/index.js +2 -0
  20. package/dist/pg/schema/index.js.map +1 -1
  21. package/dist/pg/schema/join.d.ts +406 -0
  22. package/dist/pg/schema/join.d.ts.map +1 -0
  23. package/dist/pg/schema/join.js +37 -0
  24. package/dist/pg/schema/join.js.map +1 -0
  25. package/dist/pg/schema/profiles.d.ts +17 -0
  26. package/dist/pg/schema/profiles.d.ts.map +1 -1
  27. package/dist/pg/schema/profiles.js +3 -0
  28. package/dist/pg/schema/profiles.js.map +1 -1
  29. package/dist/schema/flows.d.ts +36 -0
  30. package/dist/schema/flows.d.ts.map +1 -1
  31. package/dist/schema/flows.js +2 -0
  32. package/dist/schema/flows.js.map +1 -1
  33. package/package.json +5 -1
  34. package/src/crypto.test.ts +33 -0
  35. package/src/crypto.ts +73 -0
  36. package/src/pg/crypto.ts +4 -44
  37. package/src/pg/schema/gateway.ts +37 -0
  38. package/src/pg/schema/index.ts +2 -0
  39. package/src/pg/schema/join.ts +38 -0
  40. package/src/pg/schema/profiles.ts +3 -0
  41. package/src/schema/flows.ts +2 -0
package/src/crypto.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Canonical app-level secret encryption for the Minion stack (R7 of
2
+ // specs/2026-05-26-auth-token-simplification.md). AES-256-GCM, dialect-agnostic
3
+ // — consumed by the PG identity path (sealSecret/openSecret) and re-exported by
4
+ // minion_hub's crypto.ts (encrypt/decrypt/encryptToken/decryptToken) so there is
5
+ // ONE implementation and one key-derivation path instead of byte-matched copies.
6
+ //
7
+ // Layout (MUST stay stable — existing ciphertext at rest depends on it):
8
+ // key = scryptSync(ENCRYPTION_KEY, 'minion-hub-salt', 32)
9
+ // ciphertext = hex(encrypted || authTag) (16-byte GCM tag LAST)
10
+ // iv = hex(12 random bytes), stored separately
11
+
12
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
13
+
14
+ const ALGORITHM = "aes-256-gcm";
15
+ const IV_BYTES = 12;
16
+ const AUTH_TAG_BYTES = 16;
17
+
18
+ let cachedKey: Buffer | null = null;
19
+ function key(): Buffer {
20
+ if (cachedKey) return cachedKey;
21
+ const raw = process.env.ENCRYPTION_KEY;
22
+ if (!raw) {
23
+ if (process.env.NODE_ENV === "production") {
24
+ throw new Error("ENCRYPTION_KEY environment variable must be set in production");
25
+ }
26
+ // Dev-only fallback — never used in production.
27
+ cachedKey = scryptSync("minion-hub-dev-key", "minion-hub-salt", 32);
28
+ return cachedKey;
29
+ }
30
+ cachedKey = scryptSync(raw, "minion-hub-salt", 32);
31
+ return cachedKey;
32
+ }
33
+
34
+ /** Seal plaintext → { ciphertext, iv }. ciphertext = hex(encrypted || authTag). */
35
+ export function sealSecret(plaintext: string): { ciphertext: string; iv: string } {
36
+ const iv = randomBytes(IV_BYTES);
37
+ const cipher = createCipheriv(ALGORITHM, key(), iv);
38
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
39
+ const authTag = cipher.getAuthTag();
40
+ const combined = Buffer.concat([encrypted, authTag]);
41
+ return { ciphertext: combined.toString("hex"), iv: iv.toString("hex") };
42
+ }
43
+
44
+ /** Open hex(encrypted || authTag) + hex(iv) → plaintext. Throws on auth failure. */
45
+ export function openSecret(ciphertext: string, iv: string): string {
46
+ const combined = Buffer.from(ciphertext, "hex");
47
+ const encrypted = combined.subarray(0, combined.length - AUTH_TAG_BYTES);
48
+ const authTag = combined.subarray(combined.length - AUTH_TAG_BYTES);
49
+ const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(iv, "hex"));
50
+ decipher.setAuthTag(authTag);
51
+ return decipher.update(encrypted) + decipher.final("utf8");
52
+ }
53
+
54
+ // --- minion_hub-compatible aliases -------------------------------------------
55
+ // Hub's crypto.ts historically exported these names; keeping them lets hub become
56
+ // a thin re-export of this module without touching its many call sites.
57
+
58
+ /** Alias of {@link sealSecret}. */
59
+ export const encrypt = sealSecret;
60
+
61
+ /** Alias of {@link openSecret}. */
62
+ export const decrypt = openSecret;
63
+
64
+ /** Seal a token → { encrypted, iv } (hub's field name for the ciphertext). */
65
+ export function encryptToken(token: string): { encrypted: string; iv: string } {
66
+ const { ciphertext, iv } = sealSecret(token);
67
+ return { encrypted: ciphertext, iv };
68
+ }
69
+
70
+ /** Open a sealed token. */
71
+ export function decryptToken(encrypted: string, iv: string): string {
72
+ return openSecret(encrypted, iv);
73
+ }
package/src/pg/crypto.ts CHANGED
@@ -1,44 +1,4 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
-
3
- const ALGORITHM = 'aes-256-gcm';
4
- const IV_BYTES = 12;
5
- const AUTH_TAG_BYTES = 16;
6
-
7
- // MUST match minion_hub/src/server/auth/crypto.ts so hub + site interoperate:
8
- // key = scryptSync(ENCRYPTION_KEY, 'minion-hub-salt', 32)
9
- // ciphertext (hex) = encrypted || authTag (16-byte tag LAST)
10
- // iv (hex) = 12 random bytes, stored separately
11
- let cachedKey: Buffer | null = null;
12
- function key(): Buffer {
13
- if (cachedKey) return cachedKey;
14
- const raw = process.env.ENCRYPTION_KEY;
15
- if (!raw) {
16
- if (process.env.NODE_ENV === 'production') {
17
- throw new Error('ENCRYPTION_KEY environment variable must be set in production');
18
- }
19
- cachedKey = scryptSync('minion-hub-dev-key', 'minion-hub-salt', 32);
20
- return cachedKey;
21
- }
22
- cachedKey = scryptSync(raw, 'minion-hub-salt', 32);
23
- return cachedKey;
24
- }
25
-
26
- /** Seal plaintext → { ciphertext, iv }. ciphertext = hex(encrypted || authTag). */
27
- export function sealSecret(plaintext: string): { ciphertext: string; iv: string } {
28
- const iv = randomBytes(IV_BYTES);
29
- const cipher = createCipheriv(ALGORITHM, key(), iv);
30
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
31
- const authTag = cipher.getAuthTag();
32
- const combined = Buffer.concat([encrypted, authTag]);
33
- return { ciphertext: combined.toString('hex'), iv: iv.toString('hex') };
34
- }
35
-
36
- /** Open hex(encrypted || authTag) + hex(iv) → plaintext. Throws on auth failure. */
37
- export function openSecret(ciphertext: string, iv: string): string {
38
- const combined = Buffer.from(ciphertext, 'hex');
39
- const encrypted = combined.subarray(0, combined.length - AUTH_TAG_BYTES);
40
- const authTag = combined.subarray(combined.length - AUTH_TAG_BYTES);
41
- const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(iv, 'hex'));
42
- decipher.setAuthTag(authTag);
43
- return decipher.update(encrypted) + decipher.final('utf8');
44
- }
1
+ // Re-export of the canonical crypto module (../crypto.ts). Kept as a stable
2
+ // subpath for existing importers of the PG identity path; the implementation
3
+ // lives in one place now (R7 of specs/2026-05-26-auth-token-simplification.md).
4
+ export { sealSecret, openSecret } from "../crypto.js";
@@ -0,0 +1,37 @@
1
+ import { pgTable, uuid, text, boolean, timestamp, primaryKey, index, uniqueIndex } from 'drizzle-orm/pg-core';
2
+ import { profiles } from './profiles.js';
3
+
4
+ /**
5
+ * Supabase-backed registry of Minion gateway servers.
6
+ * Mirrors Turso `servers`. legacy_server_id preserves the old Turso text PK
7
+ * so Turso log/event rows (which still carry the old id) can join here.
8
+ */
9
+ export const gateway = pgTable('gateway', {
10
+ id: uuid('id').primaryKey().defaultRandom(),
11
+ legacyServerId: text('legacy_server_id'),
12
+ name: text('name').notNull(),
13
+ url: text('url').notNull(),
14
+ tokenCiphertext: text('token_ciphertext').notNull().default(''),
15
+ tokenIv: text('token_iv').notNull().default(''),
16
+ authMode: text('auth_mode', { enum: ['token', 'none'] }).notNull().default('token'),
17
+ lastConnectedAt: timestamp('last_connected_at', { withTimezone: true }),
18
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
19
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
20
+ }, (t) => [
21
+ uniqueIndex('gateway_uniq_url').on(t.url),
22
+ index('idx_gateway_legacy').on(t.legacyServerId),
23
+ ]);
24
+
25
+ /**
26
+ * Per-user gateway link. Mirrors Turso `user_servers`.
27
+ * profile_id references profiles.id (== auth.users.id).
28
+ */
29
+ export const userGateway = pgTable('user_gateway', {
30
+ profileId: uuid('profile_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
31
+ gatewayId: uuid('gateway_id').notNull().references(() => gateway.id, { onDelete: 'cascade' }),
32
+ isDefault: boolean('is_default').notNull().default(false),
33
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
34
+ }, (t) => [
35
+ primaryKey({ columns: [t.profileId, t.gatewayId] }),
36
+ index('idx_user_gateway_gateway').on(t.gatewayId),
37
+ ]);
@@ -1,5 +1,6 @@
1
1
  export { profiles } from './profiles.js';
2
2
  export { userIdentities } from './user-identities.js';
3
+ export { joinRequest, joinLink } from './join.js';
3
4
 
4
5
  // Identity helpers (consumed by minion_site auth path)
5
6
  export { mapGoogleIdentity } from '../identity-mapper.js';
@@ -10,3 +11,4 @@ export type {
10
11
  MappedIdentity,
11
12
  } from '../identity-mapper.js';
12
13
  export { sealSecret, openSecret } from '../crypto.js';
14
+ export { gateway, userGateway } from './gateway.js';
@@ -0,0 +1,38 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp } from 'drizzle-orm/pg-core';
2
+
3
+ // Partial unique index (one open request per user/org) is created in the
4
+ // hand-written migration (Task 5), since the WHERE-predicate form isn't
5
+ // expressed cleanly here. Keep this table definition free of an index callback.
6
+ export const joinRequest = pgTable('join_request', {
7
+ id: uuid('id').primaryKey().defaultRandom(),
8
+ // GoTrue auth.users.id (== profiles.id). The requester's Supabase identity.
9
+ supabaseId: uuid('supabase_id').notNull(),
10
+ // Bridged hub id (profiles.legacy_user_id ?? supabaseId) — the value used as the
11
+ // Turso `member.user_id` key when the request is approved. Distinct from supabase_id.
12
+ userId: text('user_id').notNull(),
13
+ email: text('email').notNull(),
14
+ displayName: text('display_name'),
15
+ message: text('message'),
16
+ status: text('status', { enum: ['pending', 'approved', 'denied'] }).notNull().default('pending'),
17
+ // Turso organization id (cross-DB reference; orgs live in Turso, so no PG FK).
18
+ organizationId: text('organization_id').notNull(),
19
+ requestedRole: text('requested_role', { enum: ['user', 'admin', 'super_admin'] }).notNull().default('user'),
20
+ reviewedBy: text('reviewed_by'),
21
+ reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
22
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
23
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
24
+ });
25
+
26
+ export const joinLink = pgTable('join_link', {
27
+ id: uuid('id').primaryKey().defaultRandom(),
28
+ token: text('token').notNull().unique(),
29
+ // Turso organization id (cross-DB reference; orgs live in Turso, so no PG FK).
30
+ organizationId: text('organization_id').notNull(),
31
+ role: text('role', { enum: ['user', 'admin', 'super_admin'] }).notNull(),
32
+ createdBy: text('created_by').notNull(),
33
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
34
+ maxUses: integer('max_uses'),
35
+ usesCount: integer('uses_count').notNull().default(0),
36
+ revoked: boolean('revoked').notNull().default(false),
37
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
38
+ });
@@ -15,6 +15,9 @@ export const profiles = pgTable('profiles', {
15
15
  .notNull()
16
16
  .default('user'),
17
17
  personalAgentId: text('personal_agent_id'),
18
+ // Better Auth `user.id` (text) this profile was migrated from. Null for
19
+ // users created natively in Supabase. Lets Phase 2 remap legacy FKs.
20
+ legacyUserId: text('legacy_user_id'),
18
21
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
19
22
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
20
23
  });
@@ -9,4 +9,6 @@ export const flows = sqliteTable('flows', {
9
9
  tenantId: text('tenant_id'), // tenant scope — null for pre-migration rows
10
10
  createdAt: integer('created_at').notNull(),
11
11
  updatedAt: integer('updated_at').notNull(),
12
+ active: integer('active', { mode: 'boolean' }).notNull().default(false),
13
+ config: text('config').notNull().default('{}'),
12
14
  });