or3-provider-sqlite 0.0.15 → 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 +6 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -308,3 +308,9 @@ Rebuild the provider before testing a file-linked host; follow the existing OR3
|
|
|
308
308
|
release/update workflow separately. Local qualification does not authorize
|
|
309
309
|
publication or a production migration. Files cloud exposure remains gated until
|
|
310
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;
|