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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
3
  "configKey": "or3-provider-sqlite",
4
- "version": "0.0.15",
4
+ "version": "0.0.16",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -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,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Additive: signup invitations never join an existing workspace. Millisecond dates. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Saluana/or3-provider-sqlite.git"