@minion-stack/db 0.2.0 → 0.3.1
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/dist/pg/crypto.d.ts +8 -0
- package/dist/pg/crypto.d.ts.map +1 -0
- package/dist/pg/crypto.js +42 -0
- package/dist/pg/crypto.js.map +1 -0
- package/dist/pg/crypto.test.d.ts +2 -0
- package/dist/pg/crypto.test.d.ts.map +1 -0
- package/dist/pg/crypto.test.js +25 -0
- package/dist/pg/crypto.test.js.map +1 -0
- package/dist/pg/identity-mapper.d.ts +36 -0
- package/dist/pg/identity-mapper.d.ts.map +1 -0
- package/dist/pg/identity-mapper.js +18 -0
- package/dist/pg/identity-mapper.js.map +1 -0
- package/dist/pg/identity-mapper.test.d.ts +2 -0
- package/dist/pg/identity-mapper.test.d.ts.map +1 -0
- package/dist/pg/identity-mapper.test.js +32 -0
- package/dist/pg/identity-mapper.test.js.map +1 -0
- package/dist/pg/schema/gateway.d.ts +262 -0
- package/dist/pg/schema/gateway.d.ts.map +1 -0
- package/dist/pg/schema/gateway.js +36 -0
- package/dist/pg/schema/gateway.js.map +1 -0
- package/dist/pg/schema/index.d.ts +8 -0
- package/dist/pg/schema/index.d.ts.map +1 -0
- package/dist/pg/schema/index.js +8 -0
- package/dist/pg/schema/index.js.map +1 -0
- package/dist/pg/schema/join.d.ts +406 -0
- package/dist/pg/schema/join.d.ts.map +1 -0
- package/dist/pg/schema/join.js +37 -0
- package/dist/pg/schema/join.js.map +1 -0
- package/dist/pg/schema/profiles.d.ts +151 -0
- package/dist/pg/schema/profiles.d.ts.map +1 -0
- package/dist/pg/schema/profiles.js +23 -0
- package/dist/pg/schema/profiles.js.map +1 -0
- package/dist/pg/schema/user-identities.d.ts +237 -0
- package/dist/pg/schema/user-identities.d.ts.map +1 -0
- package/dist/pg/schema/user-identities.js +31 -0
- package/dist/pg/schema/user-identities.js.map +1 -0
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/personal-agents.d.ts +1 -1
- package/dist/schema/reliability-events.d.ts +1 -1
- package/dist/schema/server-provision-configs.d.ts +1 -1
- package/dist/schema/session-tasks.d.ts +1 -1
- package/dist/schema/skill-execution-stats.d.ts +1 -1
- package/dist/schema/tasks.d.ts +1 -1
- package/dist/schema/user-identities.d.ts +254 -0
- package/dist/schema/user-identities.d.ts.map +1 -0
- package/dist/schema/user-identities.js +30 -0
- package/dist/schema/user-identities.js.map +1 -0
- package/dist/schema/workspace-membership.d.ts +94 -0
- package/dist/schema/workspace-membership.d.ts.map +1 -0
- package/dist/schema/workspace-membership.js +24 -0
- package/dist/schema/workspace-membership.js.map +1 -0
- package/package.json +14 -4
- package/src/pg/crypto.test.ts +28 -0
- package/src/pg/crypto.ts +44 -0
- package/src/pg/identity-mapper.test.ts +35 -0
- package/src/pg/identity-mapper.ts +48 -0
- package/src/pg/schema/gateway.ts +37 -0
- package/src/pg/schema/index.ts +14 -0
- package/src/pg/schema/join.ts +38 -0
- package/src/pg/schema/profiles.ts +23 -0
- package/src/pg/schema/user-identities.ts +35 -0
- package/src/schema/index.ts +3 -0
- package/src/schema/user-identities.ts +34 -0
- package/src/schema/workspace-membership.ts +31 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { user } from './auth/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Canonical per-user identity row. Covers OAuth providers (kind='oauth',
|
|
5
|
+
* e.g. google) and channel identities (kind='channel', e.g. whatsapp/telegram).
|
|
6
|
+
* Secret material (OAuth ADC blob) is stored app-level-encrypted as hex text
|
|
7
|
+
* in secretCiphertext/secretIv (same scheme as servers.token); null for
|
|
8
|
+
* channel identities that carry no secret.
|
|
9
|
+
*/
|
|
10
|
+
export const userIdentities = sqliteTable('user_identities', {
|
|
11
|
+
id: text('id').primaryKey(),
|
|
12
|
+
userId: text('user_id')
|
|
13
|
+
.notNull()
|
|
14
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
15
|
+
provider: text('provider').notNull(), // 'google' | 'whatsapp' | 'telegram' | 'discord' | 'signal' | 'slack'
|
|
16
|
+
kind: text('kind').notNull(), // 'oauth' | 'channel'
|
|
17
|
+
externalId: text('external_id').notNull(),
|
|
18
|
+
displayName: text('display_name'),
|
|
19
|
+
scope: text('scope'),
|
|
20
|
+
secretCiphertext: text('secret_ciphertext'),
|
|
21
|
+
secretIv: text('secret_iv'),
|
|
22
|
+
expiresAt: integer('expires_at'),
|
|
23
|
+
verifiedAt: integer('verified_at'),
|
|
24
|
+
createdAt: integer('created_at').notNull(),
|
|
25
|
+
updatedAt: integer('updated_at').notNull(),
|
|
26
|
+
}, (t) => [
|
|
27
|
+
uniqueIndex('idx_user_identity_unique').on(t.provider, t.externalId),
|
|
28
|
+
index('idx_user_identity_user').on(t.userId),
|
|
29
|
+
]);
|
|
30
|
+
//# sourceMappingURL=user-identities.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user-identities.js","sourceRoot":"","sources":["../../src/schema/user-identities.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,WAAW,CACvC,iBAAiB,EACjB;IACE,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE;IAC3B,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC;SACpB,OAAO,EAAE;SACT,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;IACrD,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,OAAO,EAAE,EAAE,sEAAsE;IAC5G,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,sBAAsB;IACpD,UAAU,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC,OAAO,EAAE;IACzC,WAAW,EAAE,IAAI,CAAC,cAAc,CAAC;IACjC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC;IACpB,gBAAgB,EAAE,IAAI,CAAC,mBAAmB,CAAC;IAC3C,QAAQ,EAAE,IAAI,CAAC,WAAW,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC;IAChC,UAAU,EAAE,OAAO,CAAC,aAAa,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;IAC1C,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,OAAO,EAAE;CAC3C,EACD,CAAC,CAAC,EAAE,EAAE,CAAC;IACL,WAAW,CAAC,0BAA0B,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,UAAU,CAAC;IACpE,KAAK,CAAC,wBAAwB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;CAC7C,CACF,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace_membership — bridge between hub identity and Paperclip company tenancy.
|
|
3
|
+
*
|
|
4
|
+
* Each row means "user X holds role Y in Paperclip company Z".
|
|
5
|
+
* Composite PK: a user may belong to many Paperclip companies.
|
|
6
|
+
*
|
|
7
|
+
* Used by:
|
|
8
|
+
* - CompanySwitcher (Task 10): lists rows for the current user
|
|
9
|
+
* - JWT mint (Task 8): uses the selected row's paperclipCompanyId as the JWT companyId claim
|
|
10
|
+
*/
|
|
11
|
+
export declare const workspaceMembership: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
12
|
+
name: "workspace_membership";
|
|
13
|
+
schema: undefined;
|
|
14
|
+
columns: {
|
|
15
|
+
userId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
16
|
+
name: "user_id";
|
|
17
|
+
tableName: "workspace_membership";
|
|
18
|
+
dataType: "string";
|
|
19
|
+
columnType: "SQLiteText";
|
|
20
|
+
data: string;
|
|
21
|
+
driverParam: string;
|
|
22
|
+
notNull: true;
|
|
23
|
+
hasDefault: false;
|
|
24
|
+
isPrimaryKey: false;
|
|
25
|
+
isAutoincrement: false;
|
|
26
|
+
hasRuntimeDefault: false;
|
|
27
|
+
enumValues: [string, ...string[]];
|
|
28
|
+
baseColumn: never;
|
|
29
|
+
identity: undefined;
|
|
30
|
+
generated: undefined;
|
|
31
|
+
}, {}, {
|
|
32
|
+
length: number | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
paperclipCompanyId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
35
|
+
name: "paperclip_company_id";
|
|
36
|
+
tableName: "workspace_membership";
|
|
37
|
+
dataType: "string";
|
|
38
|
+
columnType: "SQLiteText";
|
|
39
|
+
data: string;
|
|
40
|
+
driverParam: string;
|
|
41
|
+
notNull: true;
|
|
42
|
+
hasDefault: false;
|
|
43
|
+
isPrimaryKey: false;
|
|
44
|
+
isAutoincrement: false;
|
|
45
|
+
hasRuntimeDefault: false;
|
|
46
|
+
enumValues: [string, ...string[]];
|
|
47
|
+
baseColumn: never;
|
|
48
|
+
identity: undefined;
|
|
49
|
+
generated: undefined;
|
|
50
|
+
}, {}, {
|
|
51
|
+
length: number | undefined;
|
|
52
|
+
}>;
|
|
53
|
+
role: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
54
|
+
name: "role";
|
|
55
|
+
tableName: "workspace_membership";
|
|
56
|
+
dataType: "string";
|
|
57
|
+
columnType: "SQLiteText";
|
|
58
|
+
data: string;
|
|
59
|
+
driverParam: string;
|
|
60
|
+
notNull: true;
|
|
61
|
+
hasDefault: true;
|
|
62
|
+
isPrimaryKey: false;
|
|
63
|
+
isAutoincrement: false;
|
|
64
|
+
hasRuntimeDefault: false;
|
|
65
|
+
enumValues: [string, ...string[]];
|
|
66
|
+
baseColumn: never;
|
|
67
|
+
identity: undefined;
|
|
68
|
+
generated: undefined;
|
|
69
|
+
}, {}, {
|
|
70
|
+
length: number | undefined;
|
|
71
|
+
}>;
|
|
72
|
+
createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
73
|
+
name: "created_at";
|
|
74
|
+
tableName: "workspace_membership";
|
|
75
|
+
dataType: "date";
|
|
76
|
+
columnType: "SQLiteTimestamp";
|
|
77
|
+
data: Date;
|
|
78
|
+
driverParam: number;
|
|
79
|
+
notNull: true;
|
|
80
|
+
hasDefault: false;
|
|
81
|
+
isPrimaryKey: false;
|
|
82
|
+
isAutoincrement: false;
|
|
83
|
+
hasRuntimeDefault: false;
|
|
84
|
+
enumValues: undefined;
|
|
85
|
+
baseColumn: never;
|
|
86
|
+
identity: undefined;
|
|
87
|
+
generated: undefined;
|
|
88
|
+
}, {}, {}>;
|
|
89
|
+
};
|
|
90
|
+
dialect: "sqlite";
|
|
91
|
+
}>;
|
|
92
|
+
export type WorkspaceMembership = typeof workspaceMembership.$inferSelect;
|
|
93
|
+
export type NewWorkspaceMembership = typeof workspaceMembership.$inferInsert;
|
|
94
|
+
//# sourceMappingURL=workspace-membership.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-membership.d.ts","sourceRoot":"","sources":["../../src/schema/workspace-membership.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAc/B,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC;AAC1E,MAAM,MAAM,sBAAsB,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, index, primaryKey } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { user } from './auth/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* workspace_membership — bridge between hub identity and Paperclip company tenancy.
|
|
5
|
+
*
|
|
6
|
+
* Each row means "user X holds role Y in Paperclip company Z".
|
|
7
|
+
* Composite PK: a user may belong to many Paperclip companies.
|
|
8
|
+
*
|
|
9
|
+
* Used by:
|
|
10
|
+
* - CompanySwitcher (Task 10): lists rows for the current user
|
|
11
|
+
* - JWT mint (Task 8): uses the selected row's paperclipCompanyId as the JWT companyId claim
|
|
12
|
+
*/
|
|
13
|
+
export const workspaceMembership = sqliteTable('workspace_membership', {
|
|
14
|
+
userId: text('user_id')
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
17
|
+
paperclipCompanyId: text('paperclip_company_id').notNull(),
|
|
18
|
+
role: text('role').notNull().default('admin'),
|
|
19
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
20
|
+
}, (t) => [
|
|
21
|
+
primaryKey({ columns: [t.userId, t.paperclipCompanyId] }),
|
|
22
|
+
index('idx_workspace_membership_user').on(t.userId),
|
|
23
|
+
]);
|
|
24
|
+
//# sourceMappingURL=workspace-membership.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-membership.js","sourceRoot":"","sources":["../../src/schema/workspace-membership.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACxF,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEvC;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,WAAW,CAC5C,sBAAsB,EACtB;IACE,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC;SACpB,OAAO,EAAE;SACT,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;IACrD,kBAAkB,EAAE,IAAI,CAAC,sBAAsB,CAAC,CAAC,OAAO,EAAE;IAC1D,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAC7C,SAAS,EAAE,OAAO,CAAC,YAAY,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC,OAAO,EAAE;CAClE,EACD,CAAC,CAAC,EAAE,EAAE,CAAC;IACL,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,kBAAkB,CAAC,EAAE,CAAC;IACzD,KAAK,CAAC,+BAA+B,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;CACpD,CACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minion-stack/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Drizzle ORM schema for the Minion shared database (LibSQL/Turso).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./dist/schema/auth/index.d.ts",
|
|
25
25
|
"import": "./dist/schema/auth/index.js"
|
|
26
26
|
},
|
|
27
|
+
"./pg": {
|
|
28
|
+
"types": "./dist/pg/schema/index.d.ts",
|
|
29
|
+
"import": "./dist/pg/schema/index.js"
|
|
30
|
+
},
|
|
27
31
|
"./relations": {
|
|
28
32
|
"types": "./dist/relations.d.ts",
|
|
29
33
|
"import": "./dist/relations.js"
|
|
@@ -43,7 +47,11 @@
|
|
|
43
47
|
],
|
|
44
48
|
"scripts": {
|
|
45
49
|
"build": "tsc",
|
|
46
|
-
"prepublishOnly": "tsc"
|
|
50
|
+
"prepublishOnly": "tsc",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"lint": "oxlint src",
|
|
53
|
+
"db:pg:generate": "drizzle-kit generate --config=drizzle.pg.config.ts",
|
|
54
|
+
"test": "vitest run"
|
|
47
55
|
},
|
|
48
56
|
"peerDependencies": {
|
|
49
57
|
"drizzle-orm": ">=0.45.0"
|
|
@@ -51,8 +59,10 @@
|
|
|
51
59
|
"devDependencies": {
|
|
52
60
|
"@minion-stack/tsconfig": "workspace:*",
|
|
53
61
|
"@paralleldrive/cuid2": "^3.3.0",
|
|
54
|
-
"drizzle-orm": "^0.45.1",
|
|
55
62
|
"drizzle-kit": "^0.31.9",
|
|
56
|
-
"
|
|
63
|
+
"drizzle-orm": "^0.45.1",
|
|
64
|
+
"oxlint": "^1.66.0",
|
|
65
|
+
"typescript": "^5.0.0",
|
|
66
|
+
"vitest": "^2.1.9"
|
|
57
67
|
}
|
|
58
68
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { sealSecret, openSecret } from './crypto.js';
|
|
3
|
+
|
|
4
|
+
beforeAll(() => {
|
|
5
|
+
process.env.ENCRYPTION_KEY = 'test-key-do-not-use-in-prod';
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('identity secret crypto', () => {
|
|
9
|
+
it('round-trips a plaintext through seal/open', () => {
|
|
10
|
+
const plaintext = JSON.stringify({ type: 'authorized_user', refresh_token: 'abc123' });
|
|
11
|
+
const { ciphertext, iv } = sealSecret(plaintext);
|
|
12
|
+
expect(ciphertext).toMatch(/^[0-9a-f]+$/);
|
|
13
|
+
expect(iv).toMatch(/^[0-9a-f]+$/);
|
|
14
|
+
expect(openSecret(ciphertext, iv)).toBe(plaintext);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('fails to open with a tampered ciphertext (GCM auth)', () => {
|
|
18
|
+
const { ciphertext, iv } = sealSecret('hello');
|
|
19
|
+
const tampered = ciphertext.slice(0, -2) + (ciphertext.endsWith('00') ? '11' : '00');
|
|
20
|
+
expect(() => openSecret(tampered, iv)).toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('is wire-compatible with hub layout (ciphertext = hex(encrypted || authTag), 16-byte tag last)', () => {
|
|
24
|
+
const { ciphertext } = sealSecret('x');
|
|
25
|
+
// hex of (>=0 bytes ciphertext) + 16-byte tag → at least 32 hex chars of tag.
|
|
26
|
+
expect(ciphertext.length).toBeGreaterThanOrEqual(32);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/pg/crypto.ts
ADDED
|
@@ -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,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
|
+
]);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { profiles } from './profiles.js';
|
|
2
|
+
export { userIdentities } from './user-identities.js';
|
|
3
|
+
export { joinRequest, joinLink } from './join.js';
|
|
4
|
+
|
|
5
|
+
// Identity helpers (consumed by minion_site auth path)
|
|
6
|
+
export { mapGoogleIdentity } from '../identity-mapper.js';
|
|
7
|
+
export type {
|
|
8
|
+
GoTrueUserLike,
|
|
9
|
+
GoogleGrant,
|
|
10
|
+
MappedProfile,
|
|
11
|
+
MappedIdentity,
|
|
12
|
+
} from '../identity-mapper.js';
|
|
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
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
// 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'),
|
|
21
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
22
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
23
|
+
});
|
|
@@ -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
|
+
);
|
package/src/schema/index.ts
CHANGED
|
@@ -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;
|