@minion-stack/db 0.2.0 → 0.3.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 (57) hide show
  1. package/dist/pg/crypto.d.ts +8 -0
  2. package/dist/pg/crypto.d.ts.map +1 -0
  3. package/dist/pg/crypto.js +42 -0
  4. package/dist/pg/crypto.js.map +1 -0
  5. package/dist/pg/crypto.test.d.ts +2 -0
  6. package/dist/pg/crypto.test.d.ts.map +1 -0
  7. package/dist/pg/crypto.test.js +25 -0
  8. package/dist/pg/crypto.test.js.map +1 -0
  9. package/dist/pg/identity-mapper.d.ts +36 -0
  10. package/dist/pg/identity-mapper.d.ts.map +1 -0
  11. package/dist/pg/identity-mapper.js +18 -0
  12. package/dist/pg/identity-mapper.js.map +1 -0
  13. package/dist/pg/identity-mapper.test.d.ts +2 -0
  14. package/dist/pg/identity-mapper.test.d.ts.map +1 -0
  15. package/dist/pg/identity-mapper.test.js +32 -0
  16. package/dist/pg/identity-mapper.test.js.map +1 -0
  17. package/dist/pg/schema/index.d.ts +6 -0
  18. package/dist/pg/schema/index.d.ts.map +1 -0
  19. package/dist/pg/schema/index.js +6 -0
  20. package/dist/pg/schema/index.js.map +1 -0
  21. package/dist/pg/schema/profiles.d.ts +134 -0
  22. package/dist/pg/schema/profiles.d.ts.map +1 -0
  23. package/dist/pg/schema/profiles.js +20 -0
  24. package/dist/pg/schema/profiles.js.map +1 -0
  25. package/dist/pg/schema/user-identities.d.ts +237 -0
  26. package/dist/pg/schema/user-identities.d.ts.map +1 -0
  27. package/dist/pg/schema/user-identities.js +31 -0
  28. package/dist/pg/schema/user-identities.js.map +1 -0
  29. package/dist/schema/index.d.ts +3 -0
  30. package/dist/schema/index.d.ts.map +1 -1
  31. package/dist/schema/index.js +2 -0
  32. package/dist/schema/index.js.map +1 -1
  33. package/dist/schema/personal-agents.d.ts +1 -1
  34. package/dist/schema/reliability-events.d.ts +1 -1
  35. package/dist/schema/server-provision-configs.d.ts +1 -1
  36. package/dist/schema/session-tasks.d.ts +1 -1
  37. package/dist/schema/skill-execution-stats.d.ts +1 -1
  38. package/dist/schema/tasks.d.ts +1 -1
  39. package/dist/schema/user-identities.d.ts +254 -0
  40. package/dist/schema/user-identities.d.ts.map +1 -0
  41. package/dist/schema/user-identities.js +30 -0
  42. package/dist/schema/user-identities.js.map +1 -0
  43. package/dist/schema/workspace-membership.d.ts +94 -0
  44. package/dist/schema/workspace-membership.d.ts.map +1 -0
  45. package/dist/schema/workspace-membership.js +24 -0
  46. package/dist/schema/workspace-membership.js.map +1 -0
  47. package/package.json +18 -9
  48. package/src/pg/crypto.test.ts +28 -0
  49. package/src/pg/crypto.ts +44 -0
  50. package/src/pg/identity-mapper.test.ts +35 -0
  51. package/src/pg/identity-mapper.ts +48 -0
  52. package/src/pg/schema/index.ts +12 -0
  53. package/src/pg/schema/profiles.ts +20 -0
  54. package/src/pg/schema/user-identities.ts +35 -0
  55. package/src/schema/index.ts +3 -0
  56. package/src/schema/user-identities.ts +34 -0
  57. package/src/schema/workspace-membership.ts +31 -0
@@ -0,0 +1,44 @@
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
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mapGoogleIdentity, type GoTrueUserLike } from './identity-mapper.js';
3
+
4
+ const gotrueUser: GoTrueUserLike = {
5
+ id: '11111111-1111-1111-1111-111111111111',
6
+ email: 'nik@example.com',
7
+ user_metadata: { full_name: 'Nik P', provider_id: 'google-sub-123' },
8
+ app_metadata: { provider: 'google' },
9
+ };
10
+
11
+ describe('mapGoogleIdentity', () => {
12
+ it('produces a profile row from the GoTrue user', () => {
13
+ const { profile } = mapGoogleIdentity(gotrueUser, { refreshToken: 'rt', scope: 'email profile' });
14
+ expect(profile).toEqual({
15
+ id: '11111111-1111-1111-1111-111111111111',
16
+ email: 'nik@example.com',
17
+ displayName: 'Nik P',
18
+ });
19
+ });
20
+
21
+ it('produces a google oauth identity keyed by email as externalId', () => {
22
+ const { identity } = mapGoogleIdentity(gotrueUser, { refreshToken: 'rt', scope: 'email profile' });
23
+ expect(identity.provider).toBe('google');
24
+ expect(identity.kind).toBe('oauth');
25
+ expect(identity.externalId).toBe('nik@example.com');
26
+ expect(identity.userId).toBe(gotrueUser.id);
27
+ expect(identity.scope).toBe('email profile');
28
+ expect(identity.hasSecret).toBe(true);
29
+ });
30
+
31
+ it('marks hasSecret false when no refresh token present', () => {
32
+ const { identity } = mapGoogleIdentity(gotrueUser, { refreshToken: null, scope: null });
33
+ expect(identity.hasSecret).toBe(false);
34
+ });
35
+ });
@@ -0,0 +1,48 @@
1
+ export interface GoTrueUserLike {
2
+ id: string;
3
+ email: string | null;
4
+ user_metadata?: { full_name?: string; name?: string; provider_id?: string } | null;
5
+ app_metadata?: { provider?: string } | null;
6
+ }
7
+
8
+ export interface GoogleGrant {
9
+ refreshToken: string | null;
10
+ scope: string | null;
11
+ }
12
+
13
+ export interface MappedProfile {
14
+ id: string;
15
+ email: string;
16
+ displayName: string | null;
17
+ }
18
+
19
+ export interface MappedIdentity {
20
+ userId: string;
21
+ provider: 'google';
22
+ kind: 'oauth';
23
+ externalId: string; // google email
24
+ displayName: string | null;
25
+ scope: string | null;
26
+ hasSecret: boolean;
27
+ }
28
+
29
+ /** Pure mapping — no DB, no crypto. Caller seals the secret and upserts. */
30
+ export function mapGoogleIdentity(
31
+ user: GoTrueUserLike,
32
+ grant: GoogleGrant,
33
+ ): { profile: MappedProfile; identity: MappedIdentity } {
34
+ const email = user.email ?? '';
35
+ const displayName = user.user_metadata?.full_name ?? user.user_metadata?.name ?? null;
36
+ return {
37
+ profile: { id: user.id, email, displayName },
38
+ identity: {
39
+ userId: user.id,
40
+ provider: 'google',
41
+ kind: 'oauth',
42
+ externalId: email,
43
+ displayName,
44
+ scope: grant.scope,
45
+ hasSecret: Boolean(grant.refreshToken),
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,12 @@
1
+ export { profiles } from './profiles.js';
2
+ export { userIdentities } from './user-identities.js';
3
+
4
+ // Identity helpers (consumed by minion_site auth path)
5
+ export { mapGoogleIdentity } from '../identity-mapper.js';
6
+ export type {
7
+ GoTrueUserLike,
8
+ GoogleGrant,
9
+ MappedProfile,
10
+ MappedIdentity,
11
+ } from '../identity-mapper.js';
12
+ export { sealSecret, openSecret } from '../crypto.js';
@@ -0,0 +1,20 @@
1
+ import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
2
+
3
+ /**
4
+ * Canonical app-level user. 1:1 with GoTrue `auth.users` (id is the same uuid).
5
+ * GoTrue owns auth.users; this row is created post-signup by identity-sync.
6
+ * We intentionally do NOT add a Drizzle FK to auth.users (auth schema is
7
+ * GoTrue-managed); the FK is declared in the hand-written RLS/constraints
8
+ * migration instead.
9
+ */
10
+ export const profiles = pgTable('profiles', {
11
+ id: uuid('id').primaryKey(), // == auth.users.id
12
+ email: text('email').notNull(),
13
+ displayName: text('display_name'),
14
+ role: text('role', { enum: ['user', 'admin'] })
15
+ .notNull()
16
+ .default('user'),
17
+ personalAgentId: text('personal_agent_id'),
18
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
19
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
20
+ });
@@ -0,0 +1,35 @@
1
+ import { pgTable, uuid, text, timestamp, bigint, uniqueIndex, index } from 'drizzle-orm/pg-core';
2
+ import { profiles } from './profiles.js';
3
+
4
+ /**
5
+ * Canonical per-user identity row linking a user to BOTH OAuth providers
6
+ * (kind='oauth', e.g. google) AND channel identities (kind='channel',
7
+ * e.g. whatsapp/telegram/discord/signal/slack). This is the single link
8
+ * table replacing the SQLite channel_identities + the oauth side of account.
9
+ * Secret material (OAuth refresh-token ADC blob) is app-level AES-256-GCM
10
+ * sealed into secretCiphertext/secretIv; null for channel identities.
11
+ */
12
+ export const userIdentities = pgTable(
13
+ 'user_identities',
14
+ {
15
+ id: uuid('id').primaryKey().defaultRandom(),
16
+ userId: uuid('user_id')
17
+ .notNull()
18
+ .references(() => profiles.id, { onDelete: 'cascade' }),
19
+ provider: text('provider').notNull(), // 'google'|'whatsapp'|'telegram'|'discord'|'signal'|'slack'
20
+ kind: text('kind', { enum: ['oauth', 'channel'] }).notNull(),
21
+ externalId: text('external_id').notNull(),
22
+ displayName: text('display_name'),
23
+ scope: text('scope'),
24
+ secretCiphertext: text('secret_ciphertext'),
25
+ secretIv: text('secret_iv'),
26
+ expiresAt: bigint('expires_at', { mode: 'number' }),
27
+ verifiedAt: bigint('verified_at', { mode: 'number' }),
28
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
29
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
30
+ },
31
+ (t) => [
32
+ uniqueIndex('idx_user_identity_unique').on(t.provider, t.externalId),
33
+ index('idx_user_identity_user').on(t.userId),
34
+ ],
35
+ );
@@ -44,6 +44,7 @@ export { serverBackups } from './server-backups.js';
44
44
  export { agentGroups, agentGroupMembers } from './agent-groups.js';
45
45
  export { unifiedEvents } from './unified-events.js';
46
46
  export { channelIdentities } from './channel-identities.js';
47
+ export { userIdentities } from './user-identities.js';
47
48
  export { personalAgents } from './personal-agents.js';
48
49
  export {
49
50
  builtSkills,
@@ -57,3 +58,5 @@ export {
57
58
  agentBuiltSkills,
58
59
  } from './builder.js';
59
60
  export { userPreferences } from './user-preferences.js';
61
+ export { workspaceMembership } from './workspace-membership.js';
62
+ export type { WorkspaceMembership, NewWorkspaceMembership } from './workspace-membership.js';
@@ -0,0 +1,34 @@
1
+ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
2
+ import { user } from './auth/index.js';
3
+
4
+ /**
5
+ * Canonical per-user identity row. Covers OAuth providers (kind='oauth',
6
+ * e.g. google) and channel identities (kind='channel', e.g. whatsapp/telegram).
7
+ * Secret material (OAuth ADC blob) is stored app-level-encrypted as hex text
8
+ * in secretCiphertext/secretIv (same scheme as servers.token); null for
9
+ * channel identities that carry no secret.
10
+ */
11
+ export const userIdentities = sqliteTable(
12
+ 'user_identities',
13
+ {
14
+ id: text('id').primaryKey(),
15
+ userId: text('user_id')
16
+ .notNull()
17
+ .references(() => user.id, { onDelete: 'cascade' }),
18
+ provider: text('provider').notNull(), // 'google' | 'whatsapp' | 'telegram' | 'discord' | 'signal' | 'slack'
19
+ kind: text('kind').notNull(), // 'oauth' | 'channel'
20
+ externalId: text('external_id').notNull(),
21
+ displayName: text('display_name'),
22
+ scope: text('scope'),
23
+ secretCiphertext: text('secret_ciphertext'),
24
+ secretIv: text('secret_iv'),
25
+ expiresAt: integer('expires_at'),
26
+ verifiedAt: integer('verified_at'),
27
+ createdAt: integer('created_at').notNull(),
28
+ updatedAt: integer('updated_at').notNull(),
29
+ },
30
+ (t) => [
31
+ uniqueIndex('idx_user_identity_unique').on(t.provider, t.externalId),
32
+ index('idx_user_identity_user').on(t.userId),
33
+ ],
34
+ );
@@ -0,0 +1,31 @@
1
+ import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
2
+ import { user } from './auth/index.js';
3
+
4
+ /**
5
+ * workspace_membership — bridge between hub identity and Paperclip company tenancy.
6
+ *
7
+ * Each row means "user X holds role Y in Paperclip company Z".
8
+ * Composite PK: a user may belong to many Paperclip companies.
9
+ *
10
+ * Used by:
11
+ * - CompanySwitcher (Task 10): lists rows for the current user
12
+ * - JWT mint (Task 8): uses the selected row's paperclipCompanyId as the JWT companyId claim
13
+ */
14
+ export const workspaceMembership = sqliteTable(
15
+ 'workspace_membership',
16
+ {
17
+ userId: text('user_id')
18
+ .notNull()
19
+ .references(() => user.id, { onDelete: 'cascade' }),
20
+ paperclipCompanyId: text('paperclip_company_id').notNull(),
21
+ role: text('role').notNull().default('admin'),
22
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
23
+ },
24
+ (t) => [
25
+ primaryKey({ columns: [t.userId, t.paperclipCompanyId] }),
26
+ index('idx_workspace_membership_user').on(t.userId),
27
+ ],
28
+ );
29
+
30
+ export type WorkspaceMembership = typeof workspaceMembership.$inferSelect;
31
+ export type NewWorkspaceMembership = typeof workspaceMembership.$inferInsert;