or3-provider-sqlite 0.0.14 → 0.0.16
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 +10 -0
- package/dist/module.json +1 -1
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +41 -1
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +93 -0
- package/dist/runtime/server/db/migrate.js +4 -1
- package/dist/runtime/server/db/migrations/024_signup_invites.d.ts +4 -0
- package/dist/runtime/server/db/migrations/024_signup_invites.js +18 -0
- package/dist/runtime/server/db/schema.d.ts +17 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +2 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +21 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -291,6 +291,10 @@ The canonical-history qualification lane defaults to a sibling `../or3-chat` che
|
|
|
291
291
|
|
|
292
292
|
## Workspace Files and Trash admission
|
|
293
293
|
|
|
294
|
+
Persistent Projects uses this same capability for opaque `or3:project-settings`, `or3:project-memory`, and `or3:project-source` posts. Preserve their JSON, metadata, and every original/extraction `file_hashes` reference. The project-aware canonical reader resolves `threads.project_id` or a single legacy membership in its authorized SQLite snapshot, returning `project_ownership: resolved | conflict`. String entries and JSON-encoded membership arrays remain supported; malformed or ambiguous memberships do not grant server execution. Hosts without this result must refuse project-sensitive server admission rather than infer ownership from the visible UI.
|
|
295
|
+
|
|
296
|
+
Version 0.0.15 declares `projectOwnership: 'v1'` on the native canonical-history adapter. D1 does not advertise this contract. Projects qualification: `OR3_PROJECT_HOST_ROOT=/absolute/path/to/or3-chat bun x vitest run src/runtime/__tests__/sqlite-sync-gateway-adapter.test.ts`. This explicit lane uses the host's canonical-reader query contract while retaining the pinned standalone fixture for existing contracts. Qualify the published exact version with the disposable host cloud journey.
|
|
297
|
+
|
|
294
298
|
Native synchronous adapters advertise workspace-item capability v1. Canonical
|
|
295
299
|
posts/projects with catalog, logical Trash, or file-membership semantics require
|
|
296
300
|
a current writer even when the incoming update omits those fields. Admission is
|
|
@@ -304,3 +308,9 @@ Rebuild the provider before testing a file-linked host; follow the existing OR3
|
|
|
304
308
|
release/update workflow separately. Local qualification does not authorize
|
|
305
309
|
publication or a production migration. Files cloud exposure remains gated until
|
|
306
310
|
the supported profile is qualified.
|
|
311
|
+
|
|
312
|
+
## Instance signup invitations
|
|
313
|
+
|
|
314
|
+
The updated host distinguishes signup invites from existing team invites. Signup invites create a private workspace with only the recipient as owner; team invites retain their selected workspace and role. This requires the updated host and rebuilt provider artifacts, rather than the existing published release alone.
|
|
315
|
+
|
|
316
|
+
Native SQLite implements five optional AuthWorkspaceStore methods with one additive signup_invites migration and one immediate acceptance transaction. User, identity mapping, recipient-only workspace membership, active pointer, and acceptance receipt commit together. Replay performs no provisioning writes. D1 deliberately does not advertise these methods. Dates are milliseconds; history pages contain at most 100 rows without hashes, with 90-day terminal retention and cleanup capped at 100 records per create/list call.
|
package/dist/module.json
CHANGED
|
@@ -4,9 +4,49 @@
|
|
|
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, InviteProvisionResult, InviteValidationResult } from '~~/server/auth/store/types';
|
|
7
|
+
import type { AuthWorkspaceStore, SignupInviteSummary, SignupProvisionResult, SignupValidationResult, 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
|
+
constructor();
|
|
11
|
+
private cleanupSignupInvites;
|
|
12
|
+
createSignupInvite(input: {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
token_hash: string;
|
|
16
|
+
expires_at: number;
|
|
17
|
+
created_by_admin: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
id: string;
|
|
20
|
+
}>;
|
|
21
|
+
listSignupInvites(input: {
|
|
22
|
+
limit: number;
|
|
23
|
+
cursor?: string;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
invites: SignupInviteSummary[];
|
|
26
|
+
next_cursor: string | null;
|
|
27
|
+
}>;
|
|
28
|
+
revokeSignupInvite(input: {
|
|
29
|
+
id: string;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
ok: true;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
reason: 'not_found' | 'already_used';
|
|
35
|
+
}>;
|
|
36
|
+
private checkSignupInvite;
|
|
37
|
+
validateSignupInvite(input: {
|
|
38
|
+
id: string;
|
|
39
|
+
email: string;
|
|
40
|
+
token_hash: string;
|
|
41
|
+
}): Promise<SignupValidationResult>;
|
|
42
|
+
acceptSignupInviteAndProvisionUser(input: {
|
|
43
|
+
invite_id: string;
|
|
44
|
+
token_hash: string;
|
|
45
|
+
provider: string;
|
|
46
|
+
provider_user_id: string;
|
|
47
|
+
email: string;
|
|
48
|
+
display_name?: string;
|
|
49
|
+
}): Promise<SignupProvisionResult>;
|
|
10
50
|
private get db();
|
|
11
51
|
protected onInviteProvisionStep(_step: 'invite_validated' | 'user_created' | 'membership_created' | 'invite_consumed'): void;
|
|
12
52
|
getOrCreateUser(input: {
|
|
@@ -10,6 +10,99 @@ function normalizeEmail(email) {
|
|
|
10
10
|
return email.trim().toLowerCase();
|
|
11
11
|
}
|
|
12
12
|
export class SqliteAuthWorkspaceStore {
|
|
13
|
+
constructor() {
|
|
14
|
+
if (isD1Driver()) {
|
|
15
|
+
Object.assign(this, {
|
|
16
|
+
createSignupInvite: void 0,
|
|
17
|
+
listSignupInvites: void 0,
|
|
18
|
+
revokeSignupInvite: void 0,
|
|
19
|
+
validateSignupInvite: void 0,
|
|
20
|
+
acceptSignupInviteAndProvisionUser: void 0
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
cleanupSignupInvites() {
|
|
25
|
+
getRawDb().prepare("DELETE FROM signup_invites WHERE id IN (SELECT id FROM signup_invites WHERE retain_until < ? ORDER BY retain_until LIMIT 100)").run(Date.now());
|
|
26
|
+
}
|
|
27
|
+
async createSignupInvite(input) {
|
|
28
|
+
this.db;
|
|
29
|
+
this.cleanupSignupInvites();
|
|
30
|
+
getRawDb().prepare("INSERT INTO signup_invites (id, email, token_hash, expires_at, created_at, created_by_admin, retain_until) VALUES (?, ?, ?, ?, ?, ?, ?)").run(input.id, normalizeEmail(input.email), input.token_hash, input.expires_at, Date.now(), input.created_by_admin, input.expires_at + 90 * 864e5);
|
|
31
|
+
return { id: input.id };
|
|
32
|
+
}
|
|
33
|
+
async listSignupInvites(input) {
|
|
34
|
+
this.db;
|
|
35
|
+
this.cleanupSignupInvites();
|
|
36
|
+
let cursor = null;
|
|
37
|
+
if (input.cursor) {
|
|
38
|
+
try {
|
|
39
|
+
cursor = JSON.parse(Buffer.from(input.cursor, "base64url").toString());
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error("Invalid invite cursor");
|
|
42
|
+
}
|
|
43
|
+
if (!cursor || !Number.isSafeInteger(cursor.created_at) || typeof cursor.id !== "string") throw new Error("Invalid invite cursor");
|
|
44
|
+
}
|
|
45
|
+
const limit = Math.min(100, Math.max(1, Math.trunc(input.limit)));
|
|
46
|
+
const rows = getRawDb().prepare(
|
|
47
|
+
"SELECT id, email, status, expires_at, created_at, created_by_admin, revoked_at, accepted_at, user_id, workspace_id FROM signup_invites " + (cursor ? "WHERE (created_at, id) < (?, ?) " : "") + "ORDER BY created_at DESC, id DESC LIMIT ?"
|
|
48
|
+
).all(...cursor ? [cursor.created_at, cursor.id] : [], limit + 1);
|
|
49
|
+
const page = rows.slice(0, limit);
|
|
50
|
+
const last = page.at(-1);
|
|
51
|
+
return {
|
|
52
|
+
invites: page.map((row) => ({ ...row, status: row.status === "pending" && row.expires_at <= Date.now() ? "expired" : row.status })),
|
|
53
|
+
next_cursor: rows.length > limit && last ? Buffer.from(JSON.stringify({ created_at: last.created_at, id: last.id })).toString("base64url") : null
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async revokeSignupInvite(input) {
|
|
57
|
+
this.db;
|
|
58
|
+
const raw = getRawDb();
|
|
59
|
+
return raw.transaction(() => {
|
|
60
|
+
const invite = raw.prepare("SELECT status, expires_at FROM signup_invites WHERE id = ?").get(input.id);
|
|
61
|
+
if (!invite) return { ok: false, reason: "not_found" };
|
|
62
|
+
if (invite.status !== "pending" || invite.expires_at <= Date.now()) return { ok: false, reason: "already_used" };
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
raw.prepare("UPDATE signup_invites SET status = 'revoked', revoked_at = ?, retain_until = ? WHERE id = ?").run(now, now + 90 * 864e5, input.id);
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}).immediate();
|
|
67
|
+
}
|
|
68
|
+
checkSignupInvite(invite, input) {
|
|
69
|
+
if (!invite) return { ok: false, reason: "not_found" };
|
|
70
|
+
if (invite.token_hash !== input.token_hash) return { ok: false, reason: "token_mismatch" };
|
|
71
|
+
if (invite.email !== normalizeEmail(input.email)) return { ok: false, reason: "email_mismatch" };
|
|
72
|
+
if (invite.status === "accepted") return { ok: false, reason: "already_used" };
|
|
73
|
+
if (invite.status === "revoked") return { ok: false, reason: "revoked" };
|
|
74
|
+
if (invite.expires_at <= Date.now()) return { ok: false, reason: "expired" };
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
async validateSignupInvite(input) {
|
|
78
|
+
this.db;
|
|
79
|
+
const invite = getRawDb().prepare("SELECT * FROM signup_invites WHERE id = ?").get(input.id);
|
|
80
|
+
return this.checkSignupInvite(invite, input);
|
|
81
|
+
}
|
|
82
|
+
async acceptSignupInviteAndProvisionUser(input) {
|
|
83
|
+
this.db;
|
|
84
|
+
const raw = getRawDb();
|
|
85
|
+
return raw.transaction(() => {
|
|
86
|
+
const invite = raw.prepare("SELECT * FROM signup_invites WHERE id = ?").get(input.invite_id);
|
|
87
|
+
if (invite?.status === "accepted" && invite.token_hash === input.token_hash && invite.email === normalizeEmail(input.email) && invite.accepted_provider === input.provider && invite.accepted_provider_user_id === input.provider_user_id) {
|
|
88
|
+
return { ok: true, user_id: invite.user_id, workspace_id: invite.workspace_id, workspace_name: "My Workspace", created: false };
|
|
89
|
+
}
|
|
90
|
+
const validation = this.checkSignupInvite(invite, input);
|
|
91
|
+
if (!validation.ok) return validation;
|
|
92
|
+
const email = normalizeEmail(input.email);
|
|
93
|
+
if (raw.prepare("SELECT id FROM auth_accounts WHERE provider = ? AND provider_user_id = ?").get(input.provider, input.provider_user_id) || raw.prepare("SELECT id FROM users WHERE lower(trim(email)) = ?").get(email)) return { ok: false, reason: "account_exists" };
|
|
94
|
+
const userId = uid();
|
|
95
|
+
const workspaceId = uid();
|
|
96
|
+
const now = nowEpoch();
|
|
97
|
+
raw.prepare("INSERT INTO users (id, email, display_name, active_workspace_id, created_at) VALUES (?, ?, ?, ?, ?)").run(userId, email, input.display_name ?? null, workspaceId, now);
|
|
98
|
+
raw.prepare("INSERT INTO auth_accounts (id, user_id, provider, provider_user_id, created_at) VALUES (?, ?, ?, ?, ?)").run(uid(), userId, input.provider, input.provider_user_id, now);
|
|
99
|
+
raw.prepare("INSERT INTO workspaces (id, name, owner_user_id, created_at) VALUES (?, ?, ?, ?)").run(workspaceId, "My Workspace", userId, now);
|
|
100
|
+
raw.prepare("INSERT INTO workspace_members (id, workspace_id, user_id, role, created_at) VALUES (?, ?, ?, ?, ?)").run(uid(), workspaceId, userId, "owner", now);
|
|
101
|
+
const acceptedAt = Date.now();
|
|
102
|
+
raw.prepare("UPDATE signup_invites SET status = 'accepted', accepted_at = ?, user_id = ?, workspace_id = ?, accepted_provider = ?, accepted_provider_user_id = ?, retain_until = ? WHERE id = ?").run(acceptedAt, userId, workspaceId, input.provider, input.provider_user_id, acceptedAt + 90 * 864e5, input.invite_id);
|
|
103
|
+
return { ok: true, user_id: userId, workspace_id: workspaceId, workspace_name: "My Workspace", created: true };
|
|
104
|
+
}).immediate();
|
|
105
|
+
}
|
|
13
106
|
get db() {
|
|
14
107
|
return getSqliteDb();
|
|
15
108
|
}
|
|
@@ -39,6 +39,7 @@ const REQUIRED_SCHEMA_TABLES = [
|
|
|
39
39
|
"s_file_meta",
|
|
40
40
|
"s_notifications",
|
|
41
41
|
"auth_invites",
|
|
42
|
+
"signup_invites",
|
|
42
43
|
"admin_users",
|
|
43
44
|
"admin_workspace_settings",
|
|
44
45
|
"sync_snapshots",
|
|
@@ -51,6 +52,7 @@ const REQUIRED_SCHEMA_TABLES = [
|
|
|
51
52
|
"background_admission_cancels",
|
|
52
53
|
"background_generation_receipts"
|
|
53
54
|
];
|
|
55
|
+
import * as m024 from "./migrations/024_signup_invites.js";
|
|
54
56
|
const migrations = {
|
|
55
57
|
"001_init": m001,
|
|
56
58
|
"002_sync_tables": m002,
|
|
@@ -74,7 +76,8 @@ const migrations = {
|
|
|
74
76
|
"020_background_generation_receipts": m020,
|
|
75
77
|
"021_background_request_usage": m021,
|
|
76
78
|
"022_canonical_chat_reader": m022,
|
|
77
|
-
"023_legacy_chat_seek_index": m023
|
|
79
|
+
"023_legacy_chat_seek_index": m023,
|
|
80
|
+
"024_signup_invites": m024
|
|
78
81
|
};
|
|
79
82
|
class StaticMigrationProvider {
|
|
80
83
|
async getMigrations() {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
export async function up(db) {
|
|
3
|
+
await sql`CREATE TABLE signup_invites (
|
|
4
|
+
id TEXT PRIMARY KEY, email TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
|
5
|
+
status TEXT NOT NULL DEFAULT 'pending', expires_at INTEGER NOT NULL,
|
|
6
|
+
created_at INTEGER NOT NULL, created_by_admin TEXT NOT NULL,
|
|
7
|
+
revoked_at INTEGER, accepted_at INTEGER, user_id TEXT, workspace_id TEXT,
|
|
8
|
+
accepted_provider TEXT, accepted_provider_user_id TEXT,
|
|
9
|
+
retain_until INTEGER NOT NULL
|
|
10
|
+
)`.execute(db);
|
|
11
|
+
await sql`CREATE INDEX signup_invites_created ON signup_invites(created_at, id)`.execute(db);
|
|
12
|
+
await sql`CREATE INDEX signup_invites_retention ON signup_invites(retain_until)`.execute(db);
|
|
13
|
+
await sql`CREATE INDEX users_normalized_email ON users(lower(trim(email)))`.execute(db);
|
|
14
|
+
}
|
|
15
|
+
export async function down(db) {
|
|
16
|
+
await db.schema.dropTable("signup_invites").execute();
|
|
17
|
+
await sql`DROP INDEX users_normalized_email`.execute(db);
|
|
18
|
+
}
|
|
@@ -33,6 +33,22 @@ export interface WorkspaceMembersTable {
|
|
|
33
33
|
role: string;
|
|
34
34
|
created_at: Generated<number>;
|
|
35
35
|
}
|
|
36
|
+
export interface SignupInvitesTable {
|
|
37
|
+
id: string;
|
|
38
|
+
email: string;
|
|
39
|
+
token_hash: string;
|
|
40
|
+
status: string;
|
|
41
|
+
expires_at: number;
|
|
42
|
+
created_at: number;
|
|
43
|
+
created_by_admin: string;
|
|
44
|
+
revoked_at: number | null;
|
|
45
|
+
accepted_at: number | null;
|
|
46
|
+
user_id: string | null;
|
|
47
|
+
workspace_id: string | null;
|
|
48
|
+
accepted_provider: string | null;
|
|
49
|
+
accepted_provider_user_id: string | null;
|
|
50
|
+
retain_until: number;
|
|
51
|
+
}
|
|
36
52
|
export interface AuthInvitesTable {
|
|
37
53
|
id: string;
|
|
38
54
|
workspace_id: string;
|
|
@@ -243,6 +259,7 @@ export interface Or3SqliteDb {
|
|
|
243
259
|
workspaces: WorkspacesTable;
|
|
244
260
|
workspace_members: WorkspaceMembersTable;
|
|
245
261
|
auth_invites: AuthInvitesTable;
|
|
262
|
+
signup_invites: SignupInvitesTable;
|
|
246
263
|
server_version_counter: ServerVersionCounterTable;
|
|
247
264
|
change_log: ChangeLogTable;
|
|
248
265
|
device_cursors: DeviceCursorsTable;
|
|
@@ -17,12 +17,14 @@ export declare class SqliteSyncGatewayAdapter implements SyncGatewayAdapter {
|
|
|
17
17
|
readonly historyRetention: "snapshot-v1";
|
|
18
18
|
readonly backgroundGenerationHistory?: undefined;
|
|
19
19
|
readonly canonicalChatHistory?: undefined;
|
|
20
|
+
readonly projectOwnership?: undefined;
|
|
20
21
|
readonly workspaceItems?: undefined;
|
|
21
22
|
} | {
|
|
22
23
|
readonly snapshotBootstrap: "snapshot-v1";
|
|
23
24
|
readonly historyRetention: "snapshot-v1";
|
|
24
25
|
readonly backgroundGenerationHistory: "v1";
|
|
25
26
|
readonly canonicalChatHistory: "v1";
|
|
27
|
+
readonly projectOwnership: "v1";
|
|
26
28
|
readonly workspaceItems: "v1";
|
|
27
29
|
};
|
|
28
30
|
private get db();
|
|
@@ -433,6 +433,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
433
433
|
historyRetention: "snapshot-v1",
|
|
434
434
|
backgroundGenerationHistory: "v1",
|
|
435
435
|
canonicalChatHistory: "v1",
|
|
436
|
+
projectOwnership: "v1",
|
|
436
437
|
workspaceItems: "v1"
|
|
437
438
|
};
|
|
438
439
|
}
|
|
@@ -458,7 +459,26 @@ export class SqliteSyncGatewayAdapter {
|
|
|
458
459
|
if (query.kind === "thread") {
|
|
459
460
|
const threadRevision = raw.prepare("SELECT value FROM chat_history_revisions WHERE workspace_id = ? AND thread_id = ?").get(actor.workspaceId, query.thread_id);
|
|
460
461
|
const row = raw.prepare("SELECT id, data_json, clock, deleted FROM s_threads WHERE workspace_id = ? AND id = ?").get(actor.workspaceId, query.thread_id);
|
|
461
|
-
|
|
462
|
+
const thread = row ? parse(row) : void 0;
|
|
463
|
+
if (!thread || thread.project_id) return {
|
|
464
|
+
status: "ok",
|
|
465
|
+
project_ownership: "resolved",
|
|
466
|
+
revision: String(threadRevision?.value ?? 0),
|
|
467
|
+
thread
|
|
468
|
+
};
|
|
469
|
+
const projects = raw.prepare("SELECT id, data_json FROM s_projects WHERE workspace_id = ? AND COALESCE(deleted, 0) = 0").all(actor.workspaceId);
|
|
470
|
+
const owners = projects.filter((project) => {
|
|
471
|
+
const payload = JSON.parse(project.data_json);
|
|
472
|
+
const entries = typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data ?? [];
|
|
473
|
+
if (!Array.isArray(entries)) throw new Error("Canonical project membership is unresolved.");
|
|
474
|
+
return entries.some((entry) => typeof entry === "string" ? entry === query.thread_id : entry && typeof entry === "object" && !Array.isArray(entry) && (entry.kind === void 0 || entry.kind === "chat") && entry.id === query.thread_id);
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
status: "ok",
|
|
478
|
+
project_ownership: owners.length > 1 ? "conflict" : "resolved",
|
|
479
|
+
revision: String(threadRevision?.value ?? 0),
|
|
480
|
+
thread: { ...thread, project_id: owners[0]?.id ?? null }
|
|
481
|
+
};
|
|
462
482
|
}
|
|
463
483
|
if (query.kind === "messages") {
|
|
464
484
|
if (!query.message_ids.length) return { status: "ok", revision, messages: [] };
|