or3-provider-sqlite 0.0.1 → 0.0.3
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/README.md +20 -4
- package/dist/module.json +1 -1
- package/dist/runtime/server/admin/adapters/sync-sqlite.js +12 -34
- package/dist/runtime/server/admin/stores/sqlite-store.js +64 -9
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +16 -1
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +114 -3
- package/dist/runtime/server/connect/sqlite-connect-store.d.ts +2 -0
- package/dist/runtime/server/connect/sqlite-connect-store.js +885 -0
- package/dist/runtime/server/db/migrate.js +62 -1
- package/dist/runtime/server/db/migrations/006_sync_snapshots.d.ts +10 -0
- package/dist/runtime/server/db/migrations/006_sync_snapshots.js +106 -0
- package/dist/runtime/server/db/migrations/007_device_cursor_ownership.d.ts +7 -0
- package/dist/runtime/server/db/migrations/007_device_cursor_ownership.js +5 -0
- package/dist/runtime/server/db/migrations/008_upload_intents.d.ts +4 -0
- package/dist/runtime/server/db/migrations/008_upload_intents.js +26 -0
- package/dist/runtime/server/db/migrations/009_or3_connect.d.ts +4 -0
- package/dist/runtime/server/db/migrations/009_or3_connect.js +91 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.d.ts +10 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.js +97 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.d.ts +4 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.js +26 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.d.ts +4 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.js +124 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.d.ts +10 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.js +261 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.d.ts +4 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.js +44 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.d.ts +4 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.js +18 -0
- package/dist/runtime/server/db/schema.d.ts +97 -0
- package/dist/runtime/server/plugins/register.js +27 -5
- package/dist/runtime/server/rate-limit/sqlite-provider.d.ts +7 -0
- package/dist/runtime/server/rate-limit/sqlite-provider.js +70 -0
- package/dist/runtime/server/sync/history-gc-policy.d.ts +13 -0
- package/dist/runtime/server/sync/history-gc-policy.js +8 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +14 -2
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +901 -67
- package/dist/runtime/server/webhooks/sqlite-webhook-store.d.ts +9 -0
- package/dist/runtime/server/webhooks/sqlite-webhook-store.js +521 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ SQLite sync and workspace store provider for OR3 Chat. Provides a lightweight, s
|
|
|
5
5
|
## What it provides
|
|
6
6
|
|
|
7
7
|
- **AuthWorkspaceStore** (`sqlite`) — user identity mapping, workspace CRUD, role resolution
|
|
8
|
-
- **SyncGatewayAdapter** (`sqlite`) — push/pull sync
|
|
8
|
+
- **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, and cursor tracking
|
|
9
|
+
- **ConnectStore** (`sqlite`) — durable, atomic device enrollment and connected-computer records for OR3 Connect
|
|
9
10
|
|
|
10
11
|
## Install
|
|
11
12
|
|
|
@@ -39,6 +40,15 @@ OR3_SQLITE_PRAGMA_JOURNAL_MODE=WAL
|
|
|
39
40
|
OR3_SQLITE_PRAGMA_SYNCHRONOUS=NORMAL
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
OR3 Connect uses this same database by default:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
OR3_CONNECT_ENABLED=true
|
|
47
|
+
OR3_CONNECT_PROVIDER=sqlite
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
No Convex deployment or second database is required.
|
|
51
|
+
|
|
42
52
|
## How it works
|
|
43
53
|
|
|
44
54
|
### Registration
|
|
@@ -48,16 +58,20 @@ On server startup, the Nitro plugin:
|
|
|
48
58
|
1. Initializes the SQLite database (creates file if needed)
|
|
49
59
|
2. Runs schema migrations automatically
|
|
50
60
|
3. Registers `AuthWorkspaceStore` with ID `sqlite`
|
|
51
|
-
4. Registers `SyncGatewayAdapter`
|
|
61
|
+
4. Registers `SyncGatewayAdapter` when SQLite sync is selected
|
|
62
|
+
5. Registers `ConnectStore` when SQLite Connect persistence is selected
|
|
52
63
|
|
|
53
64
|
Registration is skipped when `auth.enabled` is `false` (local-only mode).
|
|
54
65
|
|
|
55
66
|
### Schema
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
Ordered migrations create and evolve all tables:
|
|
58
69
|
|
|
59
70
|
- **001_init**: `users`, `auth_accounts`, `workspaces`, `workspace_members`
|
|
60
71
|
- **002_sync_tables**: `server_version_counter`, `change_log`, `device_cursors`, `tombstones`, plus materialized entity tables (`s_threads`, `s_messages`, etc.)
|
|
72
|
+
- **003–005**: workspace-scoped sync keys, invitations, and admin stores
|
|
73
|
+
- **006_sync_snapshots**: winning operation IDs plus immutable snapshot headers/items
|
|
74
|
+
- **009_or3_connect**: single-use device authorizations and connected computers
|
|
61
75
|
|
|
62
76
|
All tables use snake_case aligned with the sync wire format.
|
|
63
77
|
|
|
@@ -65,8 +79,9 @@ All tables use snake_case aligned with the sync wire format.
|
|
|
65
79
|
|
|
66
80
|
- **Push**: validates ops → checks `op_id` idempotency → allocates contiguous `server_version` block → applies LWW to materialized tables → writes change_log → upserts tombstones for deletes
|
|
67
81
|
- **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
|
|
82
|
+
- **Snapshot**: captures canonical live rows and current tombstones with one `highWatermark` under `BEGIN IMMEDIATE`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`
|
|
68
83
|
- **Cursor**: forward-only per-device cursor tracking
|
|
69
|
-
- **
|
|
84
|
+
- **Retention safety**: tombstone and `change_log` GC is enabled only under the explicit `snapshot-v1` capability and deletes old revisions acknowledged by every registered device
|
|
70
85
|
|
|
71
86
|
LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
|
|
72
87
|
|
|
@@ -100,6 +115,7 @@ bun run build # build for distribution
|
|
|
100
115
|
|
|
101
116
|
- Works with multiple auth providers (`basic-auth`, `clerk`, or custom)
|
|
102
117
|
- Replaces `or3-provider-convex` for sync + workspace store functionality
|
|
118
|
+
- Provides OR3 Connect persistence without Convex
|
|
103
119
|
- Does NOT provide storage — pair with `or3-provider-fs` for file storage
|
|
104
120
|
|
|
105
121
|
### Known differences vs Convex
|
package/dist/module.json
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { createError } from "h3";
|
|
2
|
-
import {
|
|
2
|
+
import { SYNC_HISTORY_GC_POLICY } from "../../sync/history-gc-policy.js";
|
|
3
3
|
const SQLITE_PROVIDER_ID = "sqlite";
|
|
4
|
-
const DEFAULT_RETENTION_SECONDS = 30 * 24 * 3600;
|
|
5
|
-
function resolveRetentionSeconds(payload) {
|
|
6
|
-
const days = typeof payload?.retentionDays === "number" ? payload.retentionDays : null;
|
|
7
|
-
const seconds = typeof payload?.retentionSeconds === "number" ? payload.retentionSeconds : null;
|
|
8
|
-
if (seconds && Number.isFinite(seconds) && seconds > 0) return Math.floor(seconds);
|
|
9
|
-
if (days && Number.isFinite(days) && days > 0) return Math.floor(days * 24 * 3600);
|
|
10
|
-
return DEFAULT_RETENTION_SECONDS;
|
|
11
|
-
}
|
|
12
4
|
export const sqliteSyncAdminAdapter = {
|
|
13
5
|
id: SQLITE_PROVIDER_ID,
|
|
14
6
|
kind: "sync",
|
|
@@ -26,6 +18,10 @@ export const sqliteSyncAdminAdapter = {
|
|
|
26
18
|
message: "SQLite is configured with :memory:. Data will not persist across restarts."
|
|
27
19
|
});
|
|
28
20
|
}
|
|
21
|
+
warnings.push({
|
|
22
|
+
level: "warning",
|
|
23
|
+
message: SYNC_HISTORY_GC_POLICY.reason
|
|
24
|
+
});
|
|
29
25
|
return {
|
|
30
26
|
details: {
|
|
31
27
|
dbPath: dbPath ?? ":memory:",
|
|
@@ -33,39 +29,21 @@ export const sqliteSyncAdminAdapter = {
|
|
|
33
29
|
synchronous: process.env.OR3_SQLITE_PRAGMA_SYNCHRONOUS ?? "NORMAL"
|
|
34
30
|
},
|
|
35
31
|
warnings,
|
|
36
|
-
actions: [
|
|
37
|
-
{
|
|
38
|
-
id: "sync.gc-change-log",
|
|
39
|
-
label: "Run Sync Change Log GC",
|
|
40
|
-
description: "Purge old change_log entries after retention and device cursor checks.",
|
|
41
|
-
danger: true
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
id: "sync.gc-tombstones",
|
|
45
|
-
label: "Run Sync Tombstone GC",
|
|
46
|
-
description: "Purge old tombstones after retention and device cursor checks.",
|
|
47
|
-
danger: true
|
|
48
|
-
}
|
|
49
|
-
]
|
|
32
|
+
actions: []
|
|
50
33
|
};
|
|
51
34
|
},
|
|
52
|
-
async runAction(
|
|
35
|
+
async runAction(_event, actionId, _payload, ctx) {
|
|
53
36
|
if (!ctx.session.workspace?.id) {
|
|
54
37
|
throw createError({
|
|
55
38
|
statusCode: 400,
|
|
56
39
|
statusMessage: "Workspace not resolved"
|
|
57
40
|
});
|
|
58
41
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return { ok: true, action: actionId, retentionSeconds };
|
|
65
|
-
}
|
|
66
|
-
if (actionId === "sync.gc-tombstones") {
|
|
67
|
-
await adapter.gcTombstones?.(event, { scope, retentionSeconds });
|
|
68
|
-
return { ok: true, action: actionId, retentionSeconds };
|
|
42
|
+
if (actionId === "sync.gc-change-log" || actionId === "sync.gc-tombstones") {
|
|
43
|
+
throw createError({
|
|
44
|
+
statusCode: 503,
|
|
45
|
+
statusMessage: SYNC_HISTORY_GC_POLICY.reason
|
|
46
|
+
});
|
|
69
47
|
}
|
|
70
48
|
throw createError({ statusCode: 400, statusMessage: "Unknown action" });
|
|
71
49
|
}
|
|
@@ -79,25 +79,80 @@ class SqliteWorkspaceAccessStore {
|
|
|
79
79
|
).execute();
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
const existingMembership = await this.db.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", userId).executeTakeFirst();
|
|
83
|
+
if (existingMembership) {
|
|
84
|
+
await this.setMemberRole({
|
|
85
|
+
workspaceId: input.workspaceId,
|
|
86
|
+
userId,
|
|
87
|
+
role: input.role
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
82
91
|
await this.db.insertInto("workspace_members").values({
|
|
83
92
|
id: randomUUID(),
|
|
84
93
|
workspace_id: input.workspaceId,
|
|
85
94
|
user_id: userId,
|
|
86
95
|
role: input.role,
|
|
87
96
|
created_at: now
|
|
88
|
-
}).
|
|
89
|
-
(oc) => oc.columns(["workspace_id", "user_id"]).doUpdateSet({ role: input.role })
|
|
90
|
-
).execute();
|
|
97
|
+
}).execute();
|
|
91
98
|
}
|
|
92
99
|
async setMemberRole(input) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
await this.db.transaction().execute(async (tx) => {
|
|
101
|
+
const member = await tx.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
|
|
102
|
+
if (!member) {
|
|
103
|
+
throw new Error("Workspace member not found");
|
|
104
|
+
}
|
|
105
|
+
if (member.role === "owner" && input.role !== "owner") {
|
|
106
|
+
const owners = await tx.selectFrom("workspace_members").select("user_id").where("workspace_id", "=", input.workspaceId).where("role", "=", "owner").execute();
|
|
107
|
+
if (owners.length <= 1) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Cannot demote the last workspace owner. Transfer ownership first."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const workspace = await tx.selectFrom("workspaces").select("owner_user_id").where("id", "=", input.workspaceId).executeTakeFirst();
|
|
113
|
+
if (workspace?.owner_user_id === input.userId) {
|
|
114
|
+
const replacement = owners.find(
|
|
115
|
+
(owner) => owner.user_id !== input.userId
|
|
116
|
+
);
|
|
117
|
+
if (!replacement) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"Cannot demote the last workspace owner. Transfer ownership first."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
await tx.updateTable("workspaces").set({ owner_user_id: replacement.user_id }).where("id", "=", input.workspaceId).execute();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await tx.updateTable("workspace_members").set({ role: input.role }).where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
|
|
126
|
+
});
|
|
98
127
|
}
|
|
99
128
|
async removeMember(input) {
|
|
100
|
-
await this.db.
|
|
129
|
+
await this.db.transaction().execute(async (tx) => {
|
|
130
|
+
const member = await tx.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
|
|
131
|
+
if (!member) {
|
|
132
|
+
throw new Error("Workspace member not found");
|
|
133
|
+
}
|
|
134
|
+
if (member.role === "owner") {
|
|
135
|
+
const owners = await tx.selectFrom("workspace_members").select("user_id").where("workspace_id", "=", input.workspaceId).where("role", "=", "owner").execute();
|
|
136
|
+
if (owners.length <= 1) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Cannot remove the last workspace owner. Transfer ownership first."
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const workspace = await tx.selectFrom("workspaces").select("owner_user_id").where("id", "=", input.workspaceId).executeTakeFirst();
|
|
142
|
+
if (workspace?.owner_user_id === input.userId) {
|
|
143
|
+
const replacement = owners.find(
|
|
144
|
+
(owner) => owner.user_id !== input.userId
|
|
145
|
+
);
|
|
146
|
+
if (!replacement) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"Cannot remove the last workspace owner. Transfer ownership first."
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
await tx.updateTable("workspaces").set({ owner_user_id: replacement.user_id }).where("id", "=", input.workspaceId).execute();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await tx.deleteFrom("workspace_members").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
|
|
155
|
+
});
|
|
101
156
|
}
|
|
102
157
|
async listWorkspaces(input) {
|
|
103
158
|
const includeDeleted = input.includeDeleted === true;
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Maps provider identity -> internal user and manages workspace CRUD.
|
|
5
5
|
* Uses Kysely for all queries. No Convex or Clerk SDK imports.
|
|
6
6
|
*/
|
|
7
|
-
import type { AuthWorkspaceStore } from '~~/server/auth/store/types';
|
|
7
|
+
import type { AuthWorkspaceStore, InviteProvisionResult, InviteValidationResult } from '~~/server/auth/store/types';
|
|
8
8
|
import type { WorkspaceRole } from '~~/app/core/hooks/hook-types';
|
|
9
9
|
export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
|
|
10
10
|
private get db();
|
|
11
|
+
protected onInviteProvisionStep(_step: 'invite_validated' | 'user_created' | 'membership_created' | 'invite_consumed'): void;
|
|
11
12
|
getOrCreateUser(input: {
|
|
12
13
|
provider: string;
|
|
13
14
|
providerUserId: string;
|
|
@@ -27,6 +28,7 @@ export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
|
|
|
27
28
|
getOrCreateDefaultWorkspace(userId: string): Promise<{
|
|
28
29
|
workspaceId: string;
|
|
29
30
|
workspaceName: string;
|
|
31
|
+
created?: boolean;
|
|
30
32
|
}>;
|
|
31
33
|
getWorkspaceRole(input: {
|
|
32
34
|
userId: string;
|
|
@@ -95,6 +97,19 @@ export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
|
|
|
95
97
|
inviteId: string;
|
|
96
98
|
revokedByUserId: string;
|
|
97
99
|
}): Promise<void>;
|
|
100
|
+
validateInvite(input: {
|
|
101
|
+
workspaceId: string;
|
|
102
|
+
email: string;
|
|
103
|
+
tokenHash: string;
|
|
104
|
+
}): Promise<InviteValidationResult>;
|
|
105
|
+
acceptInviteAndProvisionUser(input: {
|
|
106
|
+
provider: string;
|
|
107
|
+
providerUserId: string;
|
|
108
|
+
email: string;
|
|
109
|
+
displayName?: string;
|
|
110
|
+
workspaceId: string;
|
|
111
|
+
tokenHash: string;
|
|
112
|
+
}): Promise<InviteProvisionResult>;
|
|
98
113
|
consumeInvite(input: {
|
|
99
114
|
workspaceId: string;
|
|
100
115
|
email: string;
|
|
@@ -13,6 +13,8 @@ export class SqliteAuthWorkspaceStore {
|
|
|
13
13
|
get db() {
|
|
14
14
|
return getSqliteDb();
|
|
15
15
|
}
|
|
16
|
+
onInviteProvisionStep(_step) {
|
|
17
|
+
}
|
|
16
18
|
async getOrCreateUser(input) {
|
|
17
19
|
this.db;
|
|
18
20
|
const raw = getRawDb();
|
|
@@ -94,7 +96,8 @@ export class SqliteAuthWorkspaceStore {
|
|
|
94
96
|
if (activeMembership) {
|
|
95
97
|
return {
|
|
96
98
|
workspaceId: activeMembership.id,
|
|
97
|
-
workspaceName: activeMembership.name
|
|
99
|
+
workspaceName: activeMembership.name,
|
|
100
|
+
created: false
|
|
98
101
|
};
|
|
99
102
|
}
|
|
100
103
|
const membership = await db.selectFrom("workspace_members").innerJoin("workspaces", "workspaces.id", "workspace_members.workspace_id").select([
|
|
@@ -103,7 +106,11 @@ export class SqliteAuthWorkspaceStore {
|
|
|
103
106
|
]).where("workspace_members.user_id", "=", userId).where("workspaces.deleted", "=", 0).orderBy("workspace_members.created_at", "asc").executeTakeFirst();
|
|
104
107
|
if (membership) {
|
|
105
108
|
await db.updateTable("users").set({ active_workspace_id: membership.id }).where("id", "=", userId).execute();
|
|
106
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
workspaceId: membership.id,
|
|
111
|
+
workspaceName: membership.name,
|
|
112
|
+
created: false
|
|
113
|
+
};
|
|
107
114
|
}
|
|
108
115
|
const workspaceId = uid();
|
|
109
116
|
const memberId = uid();
|
|
@@ -128,7 +135,7 @@ export class SqliteAuthWorkspaceStore {
|
|
|
128
135
|
}).execute();
|
|
129
136
|
await tx.updateTable("users").set({ active_workspace_id: workspaceId }).where("id", "=", userId).execute();
|
|
130
137
|
});
|
|
131
|
-
return { workspaceId, workspaceName: name };
|
|
138
|
+
return { workspaceId, workspaceName: name, created: true };
|
|
132
139
|
}
|
|
133
140
|
async getWorkspaceRole(input) {
|
|
134
141
|
const db = this.db;
|
|
@@ -277,6 +284,110 @@ export class SqliteAuthWorkspaceStore {
|
|
|
277
284
|
}).where("workspace_id", "=", input.workspaceId).where("id", "=", input.inviteId).execute();
|
|
278
285
|
void input.revokedByUserId;
|
|
279
286
|
}
|
|
287
|
+
async validateInvite(input) {
|
|
288
|
+
this.db;
|
|
289
|
+
const raw = getRawDb();
|
|
290
|
+
const now = nowEpoch();
|
|
291
|
+
const invite = raw.prepare(
|
|
292
|
+
`SELECT role, status, email, expires_at
|
|
293
|
+
FROM auth_invites
|
|
294
|
+
WHERE workspace_id = ? AND token_hash = ?
|
|
295
|
+
LIMIT 1`
|
|
296
|
+
).get(input.workspaceId, input.tokenHash);
|
|
297
|
+
if (!invite) return { ok: false, reason: "not_found" };
|
|
298
|
+
if (invite.email !== normalizeEmail(input.email)) {
|
|
299
|
+
return { ok: false, reason: "email_mismatch" };
|
|
300
|
+
}
|
|
301
|
+
if (invite.status === "accepted") return { ok: false, reason: "already_used" };
|
|
302
|
+
if (invite.status === "revoked") return { ok: false, reason: "revoked" };
|
|
303
|
+
if (invite.status === "expired" || invite.expires_at <= now) {
|
|
304
|
+
return { ok: false, reason: "expired" };
|
|
305
|
+
}
|
|
306
|
+
return { ok: true, role: invite.role };
|
|
307
|
+
}
|
|
308
|
+
async acceptInviteAndProvisionUser(input) {
|
|
309
|
+
this.db;
|
|
310
|
+
const raw = getRawDb();
|
|
311
|
+
const now = nowEpoch();
|
|
312
|
+
const normalizedEmail = normalizeEmail(input.email);
|
|
313
|
+
return raw.transaction(() => {
|
|
314
|
+
const invite = raw.prepare(
|
|
315
|
+
`SELECT id, role, status, email, expires_at
|
|
316
|
+
FROM auth_invites
|
|
317
|
+
WHERE workspace_id = ? AND token_hash = ?
|
|
318
|
+
LIMIT 1`
|
|
319
|
+
).get(input.workspaceId, input.tokenHash);
|
|
320
|
+
if (!invite) return { ok: false, reason: "not_found" };
|
|
321
|
+
if (invite.email !== normalizedEmail) {
|
|
322
|
+
return { ok: false, reason: "email_mismatch" };
|
|
323
|
+
}
|
|
324
|
+
if (invite.status === "accepted") {
|
|
325
|
+
return { ok: false, reason: "already_used" };
|
|
326
|
+
}
|
|
327
|
+
if (invite.status === "revoked") {
|
|
328
|
+
return { ok: false, reason: "revoked" };
|
|
329
|
+
}
|
|
330
|
+
if (invite.status === "expired" || invite.expires_at <= now) {
|
|
331
|
+
return { ok: false, reason: "expired" };
|
|
332
|
+
}
|
|
333
|
+
this.onInviteProvisionStep("invite_validated");
|
|
334
|
+
const existingAccount = raw.prepare(
|
|
335
|
+
`SELECT user_id
|
|
336
|
+
FROM auth_accounts
|
|
337
|
+
WHERE provider = ? AND provider_user_id = ?
|
|
338
|
+
LIMIT 1`
|
|
339
|
+
).get(input.provider, input.providerUserId);
|
|
340
|
+
const userId = existingAccount?.user_id ?? uid();
|
|
341
|
+
const createdUser = !existingAccount;
|
|
342
|
+
if (createdUser) {
|
|
343
|
+
raw.prepare(
|
|
344
|
+
`INSERT INTO users (id, email, display_name, active_workspace_id, created_at)
|
|
345
|
+
VALUES (?, ?, ?, NULL, ?)`
|
|
346
|
+
).run(userId, normalizedEmail, input.displayName ?? null, now);
|
|
347
|
+
raw.prepare(
|
|
348
|
+
`INSERT INTO auth_accounts (id, user_id, provider, provider_user_id, created_at)
|
|
349
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
350
|
+
).run(uid(), userId, input.provider, input.providerUserId, now);
|
|
351
|
+
}
|
|
352
|
+
this.onInviteProvisionStep("user_created");
|
|
353
|
+
const existingMembership = raw.prepare(
|
|
354
|
+
`SELECT id FROM workspace_members
|
|
355
|
+
WHERE workspace_id = ? AND user_id = ?
|
|
356
|
+
LIMIT 1`
|
|
357
|
+
).get(input.workspaceId, userId);
|
|
358
|
+
if (existingMembership) {
|
|
359
|
+
raw.prepare("UPDATE workspace_members SET role = ? WHERE id = ?").run(
|
|
360
|
+
invite.role,
|
|
361
|
+
existingMembership.id
|
|
362
|
+
);
|
|
363
|
+
} else {
|
|
364
|
+
raw.prepare(
|
|
365
|
+
`INSERT INTO workspace_members (id, workspace_id, user_id, role, created_at)
|
|
366
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
367
|
+
).run(uid(), input.workspaceId, userId, invite.role, now);
|
|
368
|
+
}
|
|
369
|
+
raw.prepare("UPDATE users SET active_workspace_id = ? WHERE id = ?").run(
|
|
370
|
+
input.workspaceId,
|
|
371
|
+
userId
|
|
372
|
+
);
|
|
373
|
+
this.onInviteProvisionStep("membership_created");
|
|
374
|
+
const consumed = raw.prepare(
|
|
375
|
+
`UPDATE auth_invites
|
|
376
|
+
SET status = 'accepted', accepted_at = ?, accepted_user_id = ?, updated_at = ?
|
|
377
|
+
WHERE id = ? AND status = 'pending'`
|
|
378
|
+
).run(now, userId, now, invite.id);
|
|
379
|
+
if (consumed.changes !== 1) {
|
|
380
|
+
throw new Error("Invite state changed during atomic acceptance");
|
|
381
|
+
}
|
|
382
|
+
this.onInviteProvisionStep("invite_consumed");
|
|
383
|
+
return {
|
|
384
|
+
ok: true,
|
|
385
|
+
userId,
|
|
386
|
+
role: invite.role,
|
|
387
|
+
createdUser
|
|
388
|
+
};
|
|
389
|
+
}).immediate();
|
|
390
|
+
}
|
|
280
391
|
async consumeInvite(input) {
|
|
281
392
|
this.db;
|
|
282
393
|
const raw = getRawDb();
|