@realiizlabs/admin 0.1.0 → 0.7.0

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.
Files changed (41) hide show
  1. package/dist/auth/index.cjs +182 -0
  2. package/dist/auth/index.cjs.map +1 -0
  3. package/dist/auth/index.d.cts +178 -0
  4. package/dist/auth/index.d.ts +178 -0
  5. package/dist/auth/index.js +167 -0
  6. package/dist/auth/index.js.map +1 -0
  7. package/dist/auth-ui/index.cjs +124 -0
  8. package/dist/auth-ui/index.cjs.map +1 -0
  9. package/dist/auth-ui/index.d.cts +40 -0
  10. package/dist/auth-ui/index.d.ts +40 -0
  11. package/dist/auth-ui/index.js +81 -0
  12. package/dist/auth-ui/index.js.map +1 -0
  13. package/dist/chunk-4OCBMOEP.js +42 -0
  14. package/dist/chunk-4OCBMOEP.js.map +1 -0
  15. package/dist/chunk-IS52OXZ2.js +252 -0
  16. package/dist/chunk-IS52OXZ2.js.map +1 -0
  17. package/dist/forms/index.cjs +256 -0
  18. package/dist/forms/index.cjs.map +1 -0
  19. package/dist/forms/index.d.cts +48 -0
  20. package/dist/forms/index.d.ts +48 -0
  21. package/dist/forms/index.js +3 -0
  22. package/dist/forms/index.js.map +1 -0
  23. package/dist/forms-ui/index.cjs +584 -0
  24. package/dist/forms-ui/index.cjs.map +1 -0
  25. package/dist/forms-ui/index.d.cts +33 -0
  26. package/dist/forms-ui/index.d.ts +33 -0
  27. package/dist/forms-ui/index.js +336 -0
  28. package/dist/forms-ui/index.js.map +1 -0
  29. package/dist/git/index.cjs +283 -0
  30. package/dist/git/index.cjs.map +1 -0
  31. package/dist/git/index.d.cts +258 -0
  32. package/dist/git/index.d.ts +258 -0
  33. package/dist/git/index.js +269 -0
  34. package/dist/git/index.js.map +1 -0
  35. package/dist/index.cjs +9 -8
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.js +9 -8
  38. package/dist/index.js.map +1 -1
  39. package/dist/types-DsSMscTh.d.cts +74 -0
  40. package/dist/types-DsSMscTh.d.ts +74 -0
  41. package/package.json +55 -3
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ require('server-only');
4
+ var ssr = require('@supabase/ssr');
5
+ var supabaseJs = require('@supabase/supabase-js');
6
+
7
+ // src/auth/index.ts
8
+ function createIdentityClient(ctx) {
9
+ return ssr.createServerClient(ctx.url, ctx.anonKey, {
10
+ cookies: {
11
+ getAll: () => ctx.cookies.getAll(),
12
+ setAll: (list) => {
13
+ try {
14
+ void ctx.cookies.setAll?.(list);
15
+ } catch {
16
+ }
17
+ }
18
+ }
19
+ });
20
+ }
21
+
22
+ // src/auth/session.ts
23
+ async function getCurrentUser(client) {
24
+ const { data, error } = await client.auth.getUser();
25
+ if (error || !data.user) return null;
26
+ return { id: data.user.id, email: data.user.email ?? null };
27
+ }
28
+
29
+ // src/auth/errors.ts
30
+ var IdentityError = class extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "IdentityError";
34
+ }
35
+ };
36
+ var UnauthenticatedError = class extends IdentityError {
37
+ constructor() {
38
+ super("Not signed in");
39
+ this.name = "UnauthenticatedError";
40
+ }
41
+ };
42
+ var ForbiddenError = class extends IdentityError {
43
+ constructor(siteId, required, actual) {
44
+ super(`Requires ${required} on site "${siteId}" (you are ${actual ?? "not a member"})`);
45
+ this.name = "ForbiddenError";
46
+ this.siteId = siteId;
47
+ this.required = required;
48
+ this.actual = actual;
49
+ }
50
+ };
51
+
52
+ // src/auth/types.ts
53
+ var ROLE_RANK = { editor: 1, owner: 2, staff: 3 };
54
+
55
+ // src/auth/roles.ts
56
+ async function getSiteRole(client, siteId, userId) {
57
+ const uid = userId ?? (await getCurrentUser(client))?.id;
58
+ if (!uid) return null;
59
+ const [staff, member] = await Promise.all([
60
+ client.from("staff").select("user_id").eq("user_id", uid).maybeSingle(),
61
+ client.from("site_users").select("role").eq("user_id", uid).eq("site_id", siteId).maybeSingle()
62
+ ]);
63
+ if (staff.error) throw staff.error;
64
+ if (member.error) throw member.error;
65
+ if (staff.data) return "staff";
66
+ const role = member.data?.role;
67
+ return role === "owner" || role === "editor" ? role : null;
68
+ }
69
+ function roleAtLeast(actual, minimum) {
70
+ return actual !== null && ROLE_RANK[actual] >= ROLE_RANK[minimum];
71
+ }
72
+ async function requireSiteUser(client, siteId, opts = {}) {
73
+ const user = await getCurrentUser(client);
74
+ if (!user) throw new UnauthenticatedError();
75
+ const minimum = opts.minimumRole ?? "editor";
76
+ const role = await getSiteRole(client, siteId, user.id);
77
+ if (!roleAtLeast(role, minimum)) throw new ForbiddenError(siteId, minimum, role);
78
+ return { user, role };
79
+ }
80
+
81
+ // src/auth/callback.ts
82
+ function safeNextPath(next, fallback) {
83
+ if (!next) return fallback;
84
+ if (!/^\/(?![/\\])/.test(next)) return fallback;
85
+ if (/[\r\n]/.test(next) || /^\/[^?#]*:\/\//.test(next)) return fallback;
86
+ return next;
87
+ }
88
+ async function handleAuthCallback(client, requestUrl, opts = {}) {
89
+ const fallback = opts.fallbackPath ?? "/admin";
90
+ const url = typeof requestUrl === "string" ? new URL(requestUrl, "http://placeholder.invalid") : requestUrl;
91
+ const code = url.searchParams.get("code");
92
+ if (!code) return { ok: false, reason: "missing_code" };
93
+ const { error } = await client.auth.exchangeCodeForSession(code);
94
+ if (error) return { ok: false, reason: "exchange_failed", message: error.message };
95
+ return { ok: true, redirectTo: safeNextPath(url.searchParams.get("next"), fallback) };
96
+ }
97
+
98
+ // src/auth/signout.ts
99
+ async function signOut(client) {
100
+ const { error } = await client.auth.signOut({ scope: "local" });
101
+ if (error) throw error;
102
+ }
103
+ function createServiceClient(ctx) {
104
+ return supabaseJs.createClient(ctx.url, ctx.serviceRoleKey, {
105
+ auth: { persistSession: false, autoRefreshToken: false }
106
+ });
107
+ }
108
+ async function resolveActorRole(svc, actorId, siteId) {
109
+ const staff = await svc.from("staff").select("user_id").eq("user_id", actorId).maybeSingle();
110
+ if (staff.error) throw staff.error;
111
+ if (staff.data) return "staff";
112
+ const m = await svc.from("site_users").select("role").eq("user_id", actorId).eq("site_id", siteId).maybeSingle();
113
+ if (m.error) throw m.error;
114
+ const role = m.data?.role;
115
+ return role === "owner" || role === "editor" ? role : null;
116
+ }
117
+ function assertMayGrant(actorRole, siteId, granting) {
118
+ if (actorRole === "staff") return;
119
+ if (actorRole === "owner" && granting === "editor") return;
120
+ throw new ForbiddenError(siteId, granting === "owner" ? "staff" : "owner", actorRole);
121
+ }
122
+ async function inviteUser(ctx, input) {
123
+ const svc = createServiceClient(ctx);
124
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
125
+ assertMayGrant(actorRole, input.siteId, input.role);
126
+ const email = input.email.trim().toLowerCase();
127
+ let userId;
128
+ let invitedByEmail = false;
129
+ const invite = await svc.auth.admin.inviteUserByEmail(email, {
130
+ redirectTo: input.redirectTo,
131
+ data: { site_id: input.siteId, role: input.role }
132
+ });
133
+ if (!invite.error) {
134
+ userId = invite.data.user.id;
135
+ invitedByEmail = true;
136
+ } else if (/already|exists|registered/i.test(invite.error.message)) {
137
+ userId = await findUserIdByEmail(svc, email);
138
+ if (!userId) throw new IdentityError(`Could not find existing user ${email}`);
139
+ } else {
140
+ throw new IdentityError(`Invite failed: ${invite.error.message}`);
141
+ }
142
+ const upsert = await svc.from("site_users").upsert({ user_id: userId, site_id: input.siteId, role: input.role, invited_by: input.actor.id }, { onConflict: "user_id,site_id" });
143
+ if (upsert.error) throw upsert.error;
144
+ return { userId, siteId: input.siteId, role: input.role, invitedByEmail };
145
+ }
146
+ async function findUserIdByEmail(svc, email) {
147
+ for (let page = 1; page <= 20; page++) {
148
+ const { data, error } = await svc.auth.admin.listUsers({ page, perPage: 200 });
149
+ if (error) throw error;
150
+ const hit = data.users.find((u) => u.email?.toLowerCase() === email);
151
+ if (hit) return hit.id;
152
+ if (data.users.length < 200) break;
153
+ }
154
+ return void 0;
155
+ }
156
+ async function removeSiteUser(ctx, input) {
157
+ const svc = createServiceClient(ctx);
158
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
159
+ const target = await svc.from("site_users").select("role").eq("user_id", input.userId).eq("site_id", input.siteId).maybeSingle();
160
+ if (target.error) throw target.error;
161
+ const targetRole = target.data?.role ?? "editor";
162
+ assertMayGrant(actorRole, input.siteId, targetRole);
163
+ const del = await svc.from("site_users").delete().eq("user_id", input.userId).eq("site_id", input.siteId);
164
+ if (del.error) throw del.error;
165
+ }
166
+
167
+ exports.ForbiddenError = ForbiddenError;
168
+ exports.IdentityError = IdentityError;
169
+ exports.ROLE_RANK = ROLE_RANK;
170
+ exports.UnauthenticatedError = UnauthenticatedError;
171
+ exports.createIdentityClient = createIdentityClient;
172
+ exports.getCurrentUser = getCurrentUser;
173
+ exports.getSiteRole = getSiteRole;
174
+ exports.handleAuthCallback = handleAuthCallback;
175
+ exports.inviteUser = inviteUser;
176
+ exports.removeSiteUser = removeSiteUser;
177
+ exports.requireSiteUser = requireSiteUser;
178
+ exports.roleAtLeast = roleAtLeast;
179
+ exports.safeNextPath = safeNextPath;
180
+ exports.signOut = signOut;
181
+ //# sourceMappingURL=index.cjs.map
182
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/auth/client.ts","../../src/auth/session.ts","../../src/auth/errors.ts","../../src/auth/types.ts","../../src/auth/roles.ts","../../src/auth/callback.ts","../../src/auth/signout.ts","../../src/auth/service.ts"],"names":["createServerClient","createClient"],"mappings":";;;;;;;AAkBO,SAAS,qBAAqB,GAAA,EAAsC;AACzE,EAAA,OAAOA,sBAAA,CAAmB,GAAA,CAAI,GAAA,EAAK,GAAA,CAAI,OAAA,EAAS;AAAA,IAC9C,OAAA,EAAS;AAAA,MACP,MAAA,EAAQ,MAAM,GAAA,CAAI,OAAA,CAAQ,MAAA,EAAO;AAAA,MACjC,MAAA,EAAQ,CAAC,IAAA,KAAS;AAIhB,QAAA,IAAI;AACF,UAAA,KAAK,GAAA,CAAI,OAAA,CAAQ,MAAA,GAAS,IAAI,CAAA;AAAA,QAChC,CAAA,CAAA,MAAQ;AAAA,QAER;AAAA,MACF;AAAA;AACF,GACD,CAAA;AACH;;;ACxBA,eAAsB,eAAe,MAAA,EAAsD;AACzF,EAAA,MAAM,EAAE,IAAA,EAAM,KAAA,KAAU,MAAM,MAAA,CAAO,KAAK,OAAA,EAAQ;AAClD,EAAA,IAAI,KAAA,IAAS,CAAC,IAAA,CAAK,IAAA,EAAM,OAAO,IAAA;AAChC,EAAA,OAAO,EAAE,IAAI,IAAA,CAAK,IAAA,CAAK,IAAI,KAAA,EAAO,IAAA,CAAK,IAAA,CAAK,KAAA,IAAS,IAAA,EAAK;AAC5D;;;ACZO,IAAM,aAAA,GAAN,cAA4B,KAAA,CAAM;AAAA,EACvC,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,eAAA;AAAA,EACd;AACF;AAGO,IAAM,oBAAA,GAAN,cAAmC,aAAA,CAAc;AAAA,EACtD,WAAA,GAAc;AACZ,IAAA,KAAA,CAAM,eAAe,CAAA;AACrB,IAAA,IAAA,CAAK,IAAA,GAAO,sBAAA;AAAA,EACd;AACF;AAGO,IAAM,cAAA,GAAN,cAA6B,aAAA,CAAc;AAAA,EAKhD,WAAA,CAAY,MAAA,EAAgB,QAAA,EAAkB,MAAA,EAAuB;AACnE,IAAA,KAAA,CAAM,YAAY,QAAQ,CAAA,UAAA,EAAa,MAAM,CAAA,WAAA,EAAc,MAAA,IAAU,cAAc,CAAA,CAAA,CAAG,CAAA;AACtF,IAAA,IAAA,CAAK,IAAA,GAAO,gBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,QAAA,GAAW,QAAA;AAChB,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;;;ACLO,IAAM,YAAsC,EAAE,MAAA,EAAQ,GAAG,KAAA,EAAO,CAAA,EAAG,OAAO,CAAA;;;ACbjF,eAAsB,WAAA,CAAY,MAAA,EAAwB,MAAA,EAAgB,MAAA,EAA2C;AACnH,EAAA,MAAM,GAAA,GAAM,MAAA,IAAA,CAAW,MAAM,cAAA,CAAe,MAAM,CAAA,GAAI,EAAA;AACtD,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AAEjB,EAAA,MAAM,CAAC,KAAA,EAAO,MAAM,CAAA,GAAI,MAAM,QAAQ,GAAA,CAAI;AAAA,IACxC,MAAA,CAAO,IAAA,CAAK,OAAO,CAAA,CAAE,MAAA,CAAO,SAAS,CAAA,CAAE,EAAA,CAAG,SAAA,EAAW,GAAG,CAAA,CAAE,WAAA,EAAY;AAAA,IACtE,MAAA,CAAO,IAAA,CAAK,YAAY,CAAA,CAAE,OAAO,MAAM,CAAA,CAAE,EAAA,CAAG,SAAA,EAAW,GAAG,CAAA,CAAE,EAAA,CAAG,SAAA,EAAW,MAAM,EAAE,WAAA;AAAY,GAC/F,CAAA;AAED,EAAA,IAAI,KAAA,CAAM,KAAA,EAAO,MAAM,KAAA,CAAM,KAAA;AAC7B,EAAA,IAAI,MAAA,CAAO,KAAA,EAAO,MAAM,MAAA,CAAO,KAAA;AAE/B,EAAA,IAAI,KAAA,CAAM,MAAM,OAAO,OAAA;AACvB,EAAA,MAAM,IAAA,GAAQ,OAAO,IAAA,EAAmC,IAAA;AACxD,EAAA,OAAO,IAAA,KAAS,OAAA,IAAW,IAAA,KAAS,QAAA,GAAW,IAAA,GAAO,IAAA;AACxD;AAEO,SAAS,WAAA,CAAY,QAAyB,OAAA,EAA4B;AAC/E,EAAA,OAAO,WAAW,IAAA,IAAQ,SAAA,CAAU,MAAM,CAAA,IAAK,UAAU,OAAO,CAAA;AAClE;AAWA,eAAsB,eAAA,CACpB,MAAA,EACA,MAAA,EACA,IAAA,GAAuB,EAAC,EACyB;AACjD,EAAA,MAAM,IAAA,GAAO,MAAM,cAAA,CAAe,MAAM,CAAA;AACxC,EAAA,IAAI,CAAC,IAAA,EAAM,MAAM,IAAI,oBAAA,EAAqB;AAE1C,EAAA,MAAM,OAAA,GAAU,KAAK,WAAA,IAAe,QAAA;AACpC,EAAA,MAAM,OAAO,MAAM,WAAA,CAAY,MAAA,EAAQ,MAAA,EAAQ,KAAK,EAAE,CAAA;AACtD,EAAA,IAAI,CAAC,WAAA,CAAY,IAAA,EAAM,OAAO,CAAA,QAAS,IAAI,cAAA,CAAe,MAAA,EAAQ,OAAA,EAAS,IAAI,CAAA;AAE/E,EAAA,OAAO,EAAE,MAAM,IAAA,EAAuB;AACxC;;;AClCO,SAAS,YAAA,CAAa,MAAiC,QAAA,EAA0B;AACtF,EAAA,IAAI,CAAC,MAAM,OAAO,QAAA;AAElB,EAAA,IAAI,CAAC,cAAA,CAAe,IAAA,CAAK,IAAI,GAAG,OAAO,QAAA;AAEvC,EAAA,IAAI,QAAA,CAAS,KAAK,IAAI,CAAA,IAAK,iBAAiB,IAAA,CAAK,IAAI,GAAG,OAAO,QAAA;AAC/D,EAAA,OAAO,IAAA;AACT;AAEA,eAAsB,kBAAA,CACpB,MAAA,EACA,UAAA,EACA,IAAA,GAAwB,EAAC,EACA;AACzB,EAAA,MAAM,QAAA,GAAW,KAAK,YAAA,IAAgB,QAAA;AACtC,EAAA,MAAM,GAAA,GAAM,OAAO,UAAA,KAAe,QAAA,GAAW,IAAI,GAAA,CAAI,UAAA,EAAY,4BAA4B,CAAA,GAAI,UAAA;AACjG,EAAA,MAAM,IAAA,GAAO,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,MAAM,CAAA;AACxC,EAAA,IAAI,CAAC,IAAA,EAAM,OAAO,EAAE,EAAA,EAAI,KAAA,EAAO,QAAQ,cAAA,EAAe;AAEtD,EAAA,MAAM,EAAE,KAAA,EAAM,GAAI,MAAM,MAAA,CAAO,IAAA,CAAK,uBAAuB,IAAI,CAAA;AAC/D,EAAA,IAAI,KAAA,SAAc,EAAE,EAAA,EAAI,OAAO,MAAA,EAAQ,iBAAA,EAAmB,OAAA,EAAS,KAAA,CAAM,OAAA,EAAQ;AAEjF,EAAA,OAAO,EAAE,EAAA,EAAI,IAAA,EAAM,UAAA,EAAY,YAAA,CAAa,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,MAAM,CAAA,EAAG,QAAQ,CAAA,EAAE;AACtF;;;ACxCA,eAAsB,QAAQ,MAAA,EAAuC;AACnE,EAAA,MAAM,EAAE,KAAA,EAAM,GAAI,MAAM,MAAA,CAAO,KAAK,OAAA,CAAQ,EAAE,KAAA,EAAO,OAAA,EAAS,CAAA;AAC9D,EAAA,IAAI,OAAO,MAAM,KAAA;AACnB;ACaO,SAAS,oBAAoB,GAAA,EAAoC;AACtE,EAAA,OAAOC,uBAAA,CAAa,GAAA,CAAI,GAAA,EAAK,GAAA,CAAI,cAAA,EAAgB;AAAA,IAC/C,IAAA,EAAM,EAAE,cAAA,EAAgB,KAAA,EAAO,kBAAkB,KAAA;AAAM,GACxD,CAAA;AACH;AAoBA,eAAe,gBAAA,CAAiB,GAAA,EAAoB,OAAA,EAAiB,MAAA,EAA0C;AAC7G,EAAA,MAAM,KAAA,GAAQ,MAAM,GAAA,CAAI,IAAA,CAAK,OAAO,CAAA,CAAE,MAAA,CAAO,SAAS,CAAA,CAAE,EAAA,CAAG,SAAA,EAAW,OAAO,EAAE,WAAA,EAAY;AAC3F,EAAA,IAAI,KAAA,CAAM,KAAA,EAAO,MAAM,KAAA,CAAM,KAAA;AAC7B,EAAA,IAAI,KAAA,CAAM,MAAM,OAAO,OAAA;AACvB,EAAA,MAAM,IAAI,MAAM,GAAA,CAAI,IAAA,CAAK,YAAY,EAAE,MAAA,CAAO,MAAM,CAAA,CAAE,EAAA,CAAG,WAAW,OAAO,CAAA,CAAE,GAAG,SAAA,EAAW,MAAM,EAAE,WAAA,EAAY;AAC/G,EAAA,IAAI,CAAA,CAAE,KAAA,EAAO,MAAM,CAAA,CAAE,KAAA;AACrB,EAAA,MAAM,IAAA,GAAQ,EAAE,IAAA,EAAmC,IAAA;AACnD,EAAA,OAAO,IAAA,KAAS,OAAA,IAAW,IAAA,KAAS,QAAA,GAAW,IAAA,GAAO,IAAA;AACxD;AAEA,SAAS,cAAA,CAAe,SAAA,EAA4B,MAAA,EAAgB,QAAA,EAAoC;AACtG,EAAA,IAAI,cAAc,OAAA,EAAS;AAC3B,EAAA,IAAI,SAAA,KAAc,OAAA,IAAW,QAAA,KAAa,QAAA,EAAU;AACpD,EAAA,MAAM,IAAI,cAAA,CAAe,MAAA,EAAQ,aAAa,OAAA,GAAU,OAAA,GAAU,SAAS,SAAS,CAAA;AACtF;AAEA,eAAsB,UAAA,CAAW,KAAqB,KAAA,EAA2C;AAC/F,EAAA,MAAM,GAAA,GAAM,oBAAoB,GAAG,CAAA;AACnC,EAAA,MAAM,SAAA,GAAY,MAAM,gBAAA,CAAiB,GAAA,EAAK,MAAM,KAAA,CAAM,EAAA,EAAI,MAAM,MAAM,CAAA;AAC1E,EAAA,cAAA,CAAe,SAAA,EAAW,KAAA,CAAM,MAAA,EAAQ,KAAA,CAAM,IAAI,CAAA;AAElD,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,KAAA,CAAM,IAAA,GAAO,WAAA,EAAY;AAC7C,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI,cAAA,GAAiB,KAAA;AAErB,EAAA,MAAM,SAAS,MAAM,GAAA,CAAI,IAAA,CAAK,KAAA,CAAM,kBAAkB,KAAA,EAAO;AAAA,IAC3D,YAAY,KAAA,CAAM,UAAA;AAAA,IAClB,MAAM,EAAE,OAAA,EAAS,MAAM,MAAA,EAAQ,IAAA,EAAM,MAAM,IAAA;AAAK,GACjD,CAAA;AAED,EAAA,IAAI,CAAC,OAAO,KAAA,EAAO;AACjB,IAAA,MAAA,GAAS,MAAA,CAAO,KAAK,IAAA,CAAK,EAAA;AAC1B,IAAA,cAAA,GAAiB,IAAA;AAAA,EACnB,WAAW,4BAAA,CAA6B,IAAA,CAAK,MAAA,CAAO,KAAA,CAAM,OAAO,CAAA,EAAG;AAElE,IAAA,MAAA,GAAS,MAAM,iBAAA,CAAkB,GAAA,EAAK,KAAK,CAAA;AAC3C,IAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,aAAA,CAAc,CAAA,6BAAA,EAAgC,KAAK,CAAA,CAAE,CAAA;AAAA,EAC9E,CAAA,MAAO;AACL,IAAA,MAAM,IAAI,aAAA,CAAc,CAAA,eAAA,EAAkB,MAAA,CAAO,KAAA,CAAM,OAAO,CAAA,CAAE,CAAA;AAAA,EAClE;AAEA,EAAA,MAAM,MAAA,GAAS,MAAM,GAAA,CAClB,IAAA,CAAK,YAAY,EACjB,MAAA,CAAO,EAAE,OAAA,EAAS,MAAA,EAAQ,OAAA,EAAS,KAAA,CAAM,QAAQ,IAAA,EAAM,KAAA,CAAM,IAAA,EAAM,UAAA,EAAY,KAAA,CAAM,KAAA,CAAM,IAAG,EAAG,EAAE,UAAA,EAAY,iBAAA,EAAmB,CAAA;AACrI,EAAA,IAAI,MAAA,CAAO,KAAA,EAAO,MAAM,MAAA,CAAO,KAAA;AAE/B,EAAA,OAAO,EAAE,QAAQ,MAAA,EAAQ,KAAA,CAAM,QAAQ,IAAA,EAAM,KAAA,CAAM,MAAM,cAAA,EAAe;AAC1E;AAEA,eAAe,iBAAA,CAAkB,KAAoB,KAAA,EAA4C;AAE/F,EAAA,KAAA,IAAS,IAAA,GAAO,CAAA,EAAG,IAAA,IAAQ,EAAA,EAAI,IAAA,EAAA,EAAQ;AACrC,IAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,GAAA,CAAI,IAAA,CAAK,KAAA,CAAM,SAAA,CAAU,EAAE,IAAA,EAAM,OAAA,EAAS,KAAK,CAAA;AAC7E,IAAA,IAAI,OAAO,MAAM,KAAA;AACjB,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,CAAC,MAAM,CAAA,CAAE,KAAA,EAAO,WAAA,EAAY,KAAM,KAAK,CAAA;AACnE,IAAA,IAAI,GAAA,SAAY,GAAA,CAAI,EAAA;AACpB,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,MAAA,GAAS,GAAA,EAAK;AAAA,EAC/B;AACA,EAAA,OAAO,MAAA;AACT;AASA,eAAsB,cAAA,CAAe,KAAqB,KAAA,EAAmC;AAC3F,EAAA,MAAM,GAAA,GAAM,oBAAoB,GAAG,CAAA;AACnC,EAAA,MAAM,SAAA,GAAY,MAAM,gBAAA,CAAiB,GAAA,EAAK,MAAM,KAAA,CAAM,EAAA,EAAI,MAAM,MAAM,CAAA;AAE1E,EAAA,MAAM,SAAS,MAAM,GAAA,CAAI,KAAK,YAAY,CAAA,CAAE,OAAO,MAAM,CAAA,CAAE,GAAG,SAAA,EAAW,KAAA,CAAM,MAAM,CAAA,CAAE,EAAA,CAAG,WAAW,KAAA,CAAM,MAAM,EAAE,WAAA,EAAY;AAC/H,EAAA,IAAI,MAAA,CAAO,KAAA,EAAO,MAAM,MAAA,CAAO,KAAA;AAC/B,EAAA,MAAM,UAAA,GAAe,MAAA,CAAO,IAAA,EAAmC,IAAA,IAAQ,QAAA;AACvE,EAAA,cAAA,CAAe,SAAA,EAAW,KAAA,CAAM,MAAA,EAAQ,UAAU,CAAA;AAElD,EAAA,MAAM,MAAM,MAAM,GAAA,CAAI,IAAA,CAAK,YAAY,EAAE,MAAA,EAAO,CAAE,EAAA,CAAG,SAAA,EAAW,MAAM,MAAM,CAAA,CAAE,EAAA,CAAG,SAAA,EAAW,MAAM,MAAM,CAAA;AACxG,EAAA,IAAI,GAAA,CAAI,KAAA,EAAO,MAAM,GAAA,CAAI,KAAA;AAC3B","file":"index.cjs","sourcesContent":["/**\n * createIdentityClient — a Supabase client bound to the request's cookies.\n *\n * Built per request from arguments. A Next 16 route does:\n *\n * const store = await cookies();\n * const client = createIdentityClient({ url, anonKey, cookies: store });\n *\n * No `next` import here: the CookieStore interface is exactly what\n * `cookies()` returns and exactly what @supabase/ssr accepts.\n */\n\nimport { createServerClient } from \"@supabase/ssr\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\nimport type { IdentityContext } from \"./types\";\n\nexport type IdentityClient = SupabaseClient;\n\nexport function createIdentityClient(ctx: IdentityContext): IdentityClient {\n return createServerClient(ctx.url, ctx.anonKey, {\n cookies: {\n getAll: () => ctx.cookies.getAll(),\n setAll: (list) => {\n // Server Components can't set cookies; @supabase/ssr documents that\n // swallowing the failure there is correct because middleware/route\n // handlers refresh the session instead.\n try {\n void ctx.cookies.setAll?.(list);\n } catch {\n /* read-only cookie store */\n }\n },\n },\n });\n}\n","/**\n * getCurrentUser — who is signed in, verified against the auth server.\n *\n * `getUser()` round-trips to Supabase and validates the JWT; `getSession()`\n * only decodes the cookie and is not trustworthy on the server. Always this.\n */\n\nimport type { IdentityClient } from \"./client\";\nimport type { IdentityUser } from \"./types\";\n\nexport async function getCurrentUser(client: IdentityClient): Promise<IdentityUser | null> {\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { id: data.user.id, email: data.user.email ?? null };\n}\n","/** Typed errors so a route can map them to 401 / 403 without string matching. */\n\nexport class IdentityError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"IdentityError\";\n }\n}\n\n/** No session — send them to sign in. Maps to 401. */\nexport class UnauthenticatedError extends IdentityError {\n constructor() {\n super(\"Not signed in\");\n this.name = \"UnauthenticatedError\";\n }\n}\n\n/** Signed in, but not allowed to do this here. Maps to 403. */\nexport class ForbiddenError extends IdentityError {\n readonly siteId: string;\n readonly required: string;\n readonly actual: string | null;\n\n constructor(siteId: string, required: string, actual: string | null) {\n super(`Requires ${required} on site \"${siteId}\" (you are ${actual ?? \"not a member\"})`);\n this.name = \"ForbiddenError\";\n this.siteId = siteId;\n this.required = required;\n this.actual = actual;\n }\n}\n","/**\n * Types for the identity layer. All config arrives as arguments — never env.\n */\n\n/** Matches @supabase/ssr's CookieMethodsServer, so `next/headers` cookies() plugs in. */\nexport interface CookieStore {\n getAll(): { name: string; value: string }[] | Promise<{ name: string; value: string }[]>;\n setAll?(cookies: { name: string; value: string; options?: Record<string, unknown> }[]): void | Promise<void>;\n}\n\n/** Enough to read the signed-in user with the anon key + their cookies. */\nexport interface IdentityContext {\n url: string;\n anonKey: string;\n cookies: CookieStore;\n}\n\n/** Server-only. The service-role key bypasses RLS; the module checks roles in code first. */\nexport interface ServiceContext {\n url: string;\n serviceRoleKey: string;\n}\n\nexport type SiteRole = \"staff\" | \"owner\" | \"editor\";\n\nexport const ROLE_RANK: Record<SiteRole, number> = { editor: 1, owner: 2, staff: 3 };\n\nexport interface IdentityUser {\n id: string;\n email: string | null;\n}\n\nexport interface SiteMembership {\n userId: string;\n siteId: string;\n role: \"owner\" | \"editor\";\n}\n","/**\n * Role resolution and the request gate.\n *\n * Two selects on the caller's OWN rows — both permitted by RLS with the anon\n * key, so this never needs the service role. Staff wins over any site role.\n */\n\nimport type { IdentityClient } from \"./client\";\nimport { ForbiddenError, UnauthenticatedError } from \"./errors\";\nimport { getCurrentUser } from \"./session\";\nimport { ROLE_RANK, type IdentityUser, type SiteRole } from \"./types\";\n\nexport async function getSiteRole(client: IdentityClient, siteId: string, userId?: string): Promise<SiteRole | null> {\n const uid = userId ?? (await getCurrentUser(client))?.id;\n if (!uid) return null;\n\n const [staff, member] = await Promise.all([\n client.from(\"staff\").select(\"user_id\").eq(\"user_id\", uid).maybeSingle(),\n client.from(\"site_users\").select(\"role\").eq(\"user_id\", uid).eq(\"site_id\", siteId).maybeSingle(),\n ]);\n\n if (staff.error) throw staff.error;\n if (member.error) throw member.error;\n\n if (staff.data) return \"staff\";\n const role = (member.data as { role?: string } | null)?.role;\n return role === \"owner\" || role === \"editor\" ? role : null;\n}\n\nexport function roleAtLeast(actual: SiteRole | null, minimum: SiteRole): boolean {\n return actual !== null && ROLE_RANK[actual] >= ROLE_RANK[minimum];\n}\n\nexport interface RequireOptions {\n /** Default \"editor\" — any member of the site. */\n minimumRole?: SiteRole;\n}\n\n/**\n * The gate. Throws UnauthenticatedError (→ sign in) or ForbiddenError (→ 403);\n * otherwise returns the user and their effective role for this site.\n */\nexport async function requireSiteUser(\n client: IdentityClient,\n siteId: string,\n opts: RequireOptions = {},\n): Promise<{ user: IdentityUser; role: SiteRole }> {\n const user = await getCurrentUser(client);\n if (!user) throw new UnauthenticatedError();\n\n const minimum = opts.minimumRole ?? \"editor\";\n const role = await getSiteRole(client, siteId, user.id);\n if (!roleAtLeast(role, minimum)) throw new ForbiddenError(siteId, minimum, role);\n\n return { user, role: role as SiteRole };\n}\n","/**\n * handleAuthCallback — the emailed magic link lands here with ?code=…&next=…\n *\n * Exchanges the PKCE code for a session (cookies are written through the\n * adapter the client was built with) and says where to send the user. The\n * `next` parameter is only honoured when it is a plain same-origin path —\n * \"//evil.example\" and \"https://…\" fall back, which closes the open-redirect\n * a naive startsWith(\"/\") check leaves open.\n */\n\nimport type { IdentityClient } from \"./client\";\n\nexport type CallbackResult =\n | { ok: true; redirectTo: string }\n | { ok: false; reason: \"missing_code\" | \"exchange_failed\"; message?: string };\n\nexport interface CallbackOptions {\n /** Where to go when `next` is absent or unsafe. Default \"/admin\". */\n fallbackPath?: string;\n}\n\nexport function safeNextPath(next: string | null | undefined, fallback: string): string {\n if (!next) return fallback;\n // Exactly one leading slash, then not another slash or backslash.\n if (!/^\\/(?![/\\\\])/.test(next)) return fallback;\n // No scheme smuggling or CR/LF.\n if (/[\\r\\n]/.test(next) || /^\\/[^?#]*:\\/\\//.test(next)) return fallback;\n return next;\n}\n\nexport async function handleAuthCallback(\n client: IdentityClient,\n requestUrl: string | URL,\n opts: CallbackOptions = {},\n): Promise<CallbackResult> {\n const fallback = opts.fallbackPath ?? \"/admin\";\n const url = typeof requestUrl === \"string\" ? new URL(requestUrl, \"http://placeholder.invalid\") : requestUrl;\n const code = url.searchParams.get(\"code\");\n if (!code) return { ok: false, reason: \"missing_code\" };\n\n const { error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { ok: false, reason: \"exchange_failed\", message: error.message };\n\n return { ok: true, redirectTo: safeNextPath(url.searchParams.get(\"next\"), fallback) };\n}\n","/** signOut — clears this browser's session. The caller redirects afterwards. */\n\nimport type { IdentityClient } from \"./client\";\n\nexport async function signOut(client: IdentityClient): Promise<void> {\n const { error } = await client.auth.signOut({ scope: \"local\" });\n if (error) throw error;\n}\n","/**\n * The only file that touches the service-role key. Invite and remove.\n *\n * Authorization is checked HERE, in code, before the privileged client does\n * anything — RLS doesn't apply to the service role, so this is the guard:\n * staff → may grant owner or editor on any site\n * owner → may grant editor on their own site only\n * editor → may not invite\n *\n * `actor` is the caller's already-resolved identity (from requireSiteUser on\n * the anon client). Its role is re-resolved with the service client so a\n * caller can't lie about it.\n */\n\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { ForbiddenError, IdentityError } from \"./errors\";\nimport type { ServiceContext, SiteRole } from \"./types\";\n\nexport type ServiceClient = SupabaseClient;\n\nexport function createServiceClient(ctx: ServiceContext): ServiceClient {\n return createClient(ctx.url, ctx.serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n}\n\nexport interface InviteInput {\n email: string;\n siteId: string;\n role: \"owner\" | \"editor\";\n /** The signed-in user performing the invite. */\n actor: { id: string };\n /** Where the invite email's link lands — the site's /admin/auth/callback. */\n redirectTo: string;\n}\n\nexport interface InviteResult {\n userId: string;\n siteId: string;\n role: \"owner\" | \"editor\";\n /** false when the auth user already existed and only the membership was added. */\n invitedByEmail: boolean;\n}\n\nasync function resolveActorRole(svc: ServiceClient, actorId: string, siteId: string): Promise<SiteRole | null> {\n const staff = await svc.from(\"staff\").select(\"user_id\").eq(\"user_id\", actorId).maybeSingle();\n if (staff.error) throw staff.error;\n if (staff.data) return \"staff\";\n const m = await svc.from(\"site_users\").select(\"role\").eq(\"user_id\", actorId).eq(\"site_id\", siteId).maybeSingle();\n if (m.error) throw m.error;\n const role = (m.data as { role?: string } | null)?.role;\n return role === \"owner\" || role === \"editor\" ? role : null;\n}\n\nfunction assertMayGrant(actorRole: SiteRole | null, siteId: string, granting: \"owner\" | \"editor\"): void {\n if (actorRole === \"staff\") return;\n if (actorRole === \"owner\" && granting === \"editor\") return;\n throw new ForbiddenError(siteId, granting === \"owner\" ? \"staff\" : \"owner\", actorRole);\n}\n\nexport async function inviteUser(ctx: ServiceContext, input: InviteInput): Promise<InviteResult> {\n const svc = createServiceClient(ctx);\n const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);\n assertMayGrant(actorRole, input.siteId, input.role);\n\n const email = input.email.trim().toLowerCase();\n let userId: string | undefined;\n let invitedByEmail = false;\n\n const invite = await svc.auth.admin.inviteUserByEmail(email, {\n redirectTo: input.redirectTo,\n data: { site_id: input.siteId, role: input.role },\n });\n\n if (!invite.error) {\n userId = invite.data.user.id;\n invitedByEmail = true;\n } else if (/already|exists|registered/i.test(invite.error.message)) {\n // Existing auth user (works for another site, or is staff): just add the row.\n userId = await findUserIdByEmail(svc, email);\n if (!userId) throw new IdentityError(`Could not find existing user ${email}`);\n } else {\n throw new IdentityError(`Invite failed: ${invite.error.message}`);\n }\n\n const upsert = await svc\n .from(\"site_users\")\n .upsert({ user_id: userId, site_id: input.siteId, role: input.role, invited_by: input.actor.id }, { onConflict: \"user_id,site_id\" });\n if (upsert.error) throw upsert.error;\n\n return { userId, siteId: input.siteId, role: input.role, invitedByEmail };\n}\n\nasync function findUserIdByEmail(svc: ServiceClient, email: string): Promise<string | undefined> {\n // listUsers is paged; identity holds tens of users, not thousands.\n for (let page = 1; page <= 20; page++) {\n const { data, error } = await svc.auth.admin.listUsers({ page, perPage: 200 });\n if (error) throw error;\n const hit = data.users.find((u) => u.email?.toLowerCase() === email);\n if (hit) return hit.id;\n if (data.users.length < 200) break;\n }\n return undefined;\n}\n\nexport interface RemoveInput {\n userId: string;\n siteId: string;\n actor: { id: string };\n}\n\n/** Removes the membership only. The auth user survives (they may belong to other sites). */\nexport async function removeSiteUser(ctx: ServiceContext, input: RemoveInput): Promise<void> {\n const svc = createServiceClient(ctx);\n const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);\n\n const target = await svc.from(\"site_users\").select(\"role\").eq(\"user_id\", input.userId).eq(\"site_id\", input.siteId).maybeSingle();\n if (target.error) throw target.error;\n const targetRole = ((target.data as { role?: string } | null)?.role ?? \"editor\") as \"owner\" | \"editor\";\n assertMayGrant(actorRole, input.siteId, targetRole);\n\n const del = await svc.from(\"site_users\").delete().eq(\"user_id\", input.userId).eq(\"site_id\", input.siteId);\n if (del.error) throw del.error;\n}\n"]}
@@ -0,0 +1,178 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ /**
4
+ * Types for the identity layer. All config arrives as arguments — never env.
5
+ */
6
+ /** Matches @supabase/ssr's CookieMethodsServer, so `next/headers` cookies() plugs in. */
7
+ interface CookieStore {
8
+ getAll(): {
9
+ name: string;
10
+ value: string;
11
+ }[] | Promise<{
12
+ name: string;
13
+ value: string;
14
+ }[]>;
15
+ setAll?(cookies: {
16
+ name: string;
17
+ value: string;
18
+ options?: Record<string, unknown>;
19
+ }[]): void | Promise<void>;
20
+ }
21
+ /** Enough to read the signed-in user with the anon key + their cookies. */
22
+ interface IdentityContext {
23
+ url: string;
24
+ anonKey: string;
25
+ cookies: CookieStore;
26
+ }
27
+ /** Server-only. The service-role key bypasses RLS; the module checks roles in code first. */
28
+ interface ServiceContext {
29
+ url: string;
30
+ serviceRoleKey: string;
31
+ }
32
+ type SiteRole = "staff" | "owner" | "editor";
33
+ declare const ROLE_RANK: Record<SiteRole, number>;
34
+ interface IdentityUser {
35
+ id: string;
36
+ email: string | null;
37
+ }
38
+ interface SiteMembership {
39
+ userId: string;
40
+ siteId: string;
41
+ role: "owner" | "editor";
42
+ }
43
+
44
+ /**
45
+ * createIdentityClient — a Supabase client bound to the request's cookies.
46
+ *
47
+ * Built per request from arguments. A Next 16 route does:
48
+ *
49
+ * const store = await cookies();
50
+ * const client = createIdentityClient({ url, anonKey, cookies: store });
51
+ *
52
+ * No `next` import here: the CookieStore interface is exactly what
53
+ * `cookies()` returns and exactly what @supabase/ssr accepts.
54
+ */
55
+
56
+ type IdentityClient = SupabaseClient;
57
+ declare function createIdentityClient(ctx: IdentityContext): IdentityClient;
58
+
59
+ /**
60
+ * getCurrentUser — who is signed in, verified against the auth server.
61
+ *
62
+ * `getUser()` round-trips to Supabase and validates the JWT; `getSession()`
63
+ * only decodes the cookie and is not trustworthy on the server. Always this.
64
+ */
65
+
66
+ declare function getCurrentUser(client: IdentityClient): Promise<IdentityUser | null>;
67
+
68
+ /**
69
+ * Role resolution and the request gate.
70
+ *
71
+ * Two selects on the caller's OWN rows — both permitted by RLS with the anon
72
+ * key, so this never needs the service role. Staff wins over any site role.
73
+ */
74
+
75
+ declare function getSiteRole(client: IdentityClient, siteId: string, userId?: string): Promise<SiteRole | null>;
76
+ declare function roleAtLeast(actual: SiteRole | null, minimum: SiteRole): boolean;
77
+ interface RequireOptions {
78
+ /** Default "editor" — any member of the site. */
79
+ minimumRole?: SiteRole;
80
+ }
81
+ /**
82
+ * The gate. Throws UnauthenticatedError (→ sign in) or ForbiddenError (→ 403);
83
+ * otherwise returns the user and their effective role for this site.
84
+ */
85
+ declare function requireSiteUser(client: IdentityClient, siteId: string, opts?: RequireOptions): Promise<{
86
+ user: IdentityUser;
87
+ role: SiteRole;
88
+ }>;
89
+
90
+ /**
91
+ * handleAuthCallback — the emailed magic link lands here with ?code=…&next=…
92
+ *
93
+ * Exchanges the PKCE code for a session (cookies are written through the
94
+ * adapter the client was built with) and says where to send the user. The
95
+ * `next` parameter is only honoured when it is a plain same-origin path —
96
+ * "//evil.example" and "https://…" fall back, which closes the open-redirect
97
+ * a naive startsWith("/") check leaves open.
98
+ */
99
+
100
+ type CallbackResult = {
101
+ ok: true;
102
+ redirectTo: string;
103
+ } | {
104
+ ok: false;
105
+ reason: "missing_code" | "exchange_failed";
106
+ message?: string;
107
+ };
108
+ interface CallbackOptions {
109
+ /** Where to go when `next` is absent or unsafe. Default "/admin". */
110
+ fallbackPath?: string;
111
+ }
112
+ declare function safeNextPath(next: string | null | undefined, fallback: string): string;
113
+ declare function handleAuthCallback(client: IdentityClient, requestUrl: string | URL, opts?: CallbackOptions): Promise<CallbackResult>;
114
+
115
+ /** signOut — clears this browser's session. The caller redirects afterwards. */
116
+
117
+ declare function signOut(client: IdentityClient): Promise<void>;
118
+
119
+ /**
120
+ * The only file that touches the service-role key. Invite and remove.
121
+ *
122
+ * Authorization is checked HERE, in code, before the privileged client does
123
+ * anything — RLS doesn't apply to the service role, so this is the guard:
124
+ * staff → may grant owner or editor on any site
125
+ * owner → may grant editor on their own site only
126
+ * editor → may not invite
127
+ *
128
+ * `actor` is the caller's already-resolved identity (from requireSiteUser on
129
+ * the anon client). Its role is re-resolved with the service client so a
130
+ * caller can't lie about it.
131
+ */
132
+
133
+ interface InviteInput {
134
+ email: string;
135
+ siteId: string;
136
+ role: "owner" | "editor";
137
+ /** The signed-in user performing the invite. */
138
+ actor: {
139
+ id: string;
140
+ };
141
+ /** Where the invite email's link lands — the site's /admin/auth/callback. */
142
+ redirectTo: string;
143
+ }
144
+ interface InviteResult {
145
+ userId: string;
146
+ siteId: string;
147
+ role: "owner" | "editor";
148
+ /** false when the auth user already existed and only the membership was added. */
149
+ invitedByEmail: boolean;
150
+ }
151
+ declare function inviteUser(ctx: ServiceContext, input: InviteInput): Promise<InviteResult>;
152
+ interface RemoveInput {
153
+ userId: string;
154
+ siteId: string;
155
+ actor: {
156
+ id: string;
157
+ };
158
+ }
159
+ /** Removes the membership only. The auth user survives (they may belong to other sites). */
160
+ declare function removeSiteUser(ctx: ServiceContext, input: RemoveInput): Promise<void>;
161
+
162
+ /** Typed errors so a route can map them to 401 / 403 without string matching. */
163
+ declare class IdentityError extends Error {
164
+ constructor(message: string);
165
+ }
166
+ /** No session — send them to sign in. Maps to 401. */
167
+ declare class UnauthenticatedError extends IdentityError {
168
+ constructor();
169
+ }
170
+ /** Signed in, but not allowed to do this here. Maps to 403. */
171
+ declare class ForbiddenError extends IdentityError {
172
+ readonly siteId: string;
173
+ readonly required: string;
174
+ readonly actual: string | null;
175
+ constructor(siteId: string, required: string, actual: string | null);
176
+ }
177
+
178
+ export { type CallbackOptions, type CallbackResult, type CookieStore, ForbiddenError, type IdentityClient, type IdentityContext, IdentityError, type IdentityUser, type InviteInput, type InviteResult, ROLE_RANK, type RemoveInput, type RequireOptions, type ServiceContext, type SiteMembership, type SiteRole, UnauthenticatedError, createIdentityClient, getCurrentUser, getSiteRole, handleAuthCallback, inviteUser, removeSiteUser, requireSiteUser, roleAtLeast, safeNextPath, signOut };
@@ -0,0 +1,178 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ /**
4
+ * Types for the identity layer. All config arrives as arguments — never env.
5
+ */
6
+ /** Matches @supabase/ssr's CookieMethodsServer, so `next/headers` cookies() plugs in. */
7
+ interface CookieStore {
8
+ getAll(): {
9
+ name: string;
10
+ value: string;
11
+ }[] | Promise<{
12
+ name: string;
13
+ value: string;
14
+ }[]>;
15
+ setAll?(cookies: {
16
+ name: string;
17
+ value: string;
18
+ options?: Record<string, unknown>;
19
+ }[]): void | Promise<void>;
20
+ }
21
+ /** Enough to read the signed-in user with the anon key + their cookies. */
22
+ interface IdentityContext {
23
+ url: string;
24
+ anonKey: string;
25
+ cookies: CookieStore;
26
+ }
27
+ /** Server-only. The service-role key bypasses RLS; the module checks roles in code first. */
28
+ interface ServiceContext {
29
+ url: string;
30
+ serviceRoleKey: string;
31
+ }
32
+ type SiteRole = "staff" | "owner" | "editor";
33
+ declare const ROLE_RANK: Record<SiteRole, number>;
34
+ interface IdentityUser {
35
+ id: string;
36
+ email: string | null;
37
+ }
38
+ interface SiteMembership {
39
+ userId: string;
40
+ siteId: string;
41
+ role: "owner" | "editor";
42
+ }
43
+
44
+ /**
45
+ * createIdentityClient — a Supabase client bound to the request's cookies.
46
+ *
47
+ * Built per request from arguments. A Next 16 route does:
48
+ *
49
+ * const store = await cookies();
50
+ * const client = createIdentityClient({ url, anonKey, cookies: store });
51
+ *
52
+ * No `next` import here: the CookieStore interface is exactly what
53
+ * `cookies()` returns and exactly what @supabase/ssr accepts.
54
+ */
55
+
56
+ type IdentityClient = SupabaseClient;
57
+ declare function createIdentityClient(ctx: IdentityContext): IdentityClient;
58
+
59
+ /**
60
+ * getCurrentUser — who is signed in, verified against the auth server.
61
+ *
62
+ * `getUser()` round-trips to Supabase and validates the JWT; `getSession()`
63
+ * only decodes the cookie and is not trustworthy on the server. Always this.
64
+ */
65
+
66
+ declare function getCurrentUser(client: IdentityClient): Promise<IdentityUser | null>;
67
+
68
+ /**
69
+ * Role resolution and the request gate.
70
+ *
71
+ * Two selects on the caller's OWN rows — both permitted by RLS with the anon
72
+ * key, so this never needs the service role. Staff wins over any site role.
73
+ */
74
+
75
+ declare function getSiteRole(client: IdentityClient, siteId: string, userId?: string): Promise<SiteRole | null>;
76
+ declare function roleAtLeast(actual: SiteRole | null, minimum: SiteRole): boolean;
77
+ interface RequireOptions {
78
+ /** Default "editor" — any member of the site. */
79
+ minimumRole?: SiteRole;
80
+ }
81
+ /**
82
+ * The gate. Throws UnauthenticatedError (→ sign in) or ForbiddenError (→ 403);
83
+ * otherwise returns the user and their effective role for this site.
84
+ */
85
+ declare function requireSiteUser(client: IdentityClient, siteId: string, opts?: RequireOptions): Promise<{
86
+ user: IdentityUser;
87
+ role: SiteRole;
88
+ }>;
89
+
90
+ /**
91
+ * handleAuthCallback — the emailed magic link lands here with ?code=…&next=…
92
+ *
93
+ * Exchanges the PKCE code for a session (cookies are written through the
94
+ * adapter the client was built with) and says where to send the user. The
95
+ * `next` parameter is only honoured when it is a plain same-origin path —
96
+ * "//evil.example" and "https://…" fall back, which closes the open-redirect
97
+ * a naive startsWith("/") check leaves open.
98
+ */
99
+
100
+ type CallbackResult = {
101
+ ok: true;
102
+ redirectTo: string;
103
+ } | {
104
+ ok: false;
105
+ reason: "missing_code" | "exchange_failed";
106
+ message?: string;
107
+ };
108
+ interface CallbackOptions {
109
+ /** Where to go when `next` is absent or unsafe. Default "/admin". */
110
+ fallbackPath?: string;
111
+ }
112
+ declare function safeNextPath(next: string | null | undefined, fallback: string): string;
113
+ declare function handleAuthCallback(client: IdentityClient, requestUrl: string | URL, opts?: CallbackOptions): Promise<CallbackResult>;
114
+
115
+ /** signOut — clears this browser's session. The caller redirects afterwards. */
116
+
117
+ declare function signOut(client: IdentityClient): Promise<void>;
118
+
119
+ /**
120
+ * The only file that touches the service-role key. Invite and remove.
121
+ *
122
+ * Authorization is checked HERE, in code, before the privileged client does
123
+ * anything — RLS doesn't apply to the service role, so this is the guard:
124
+ * staff → may grant owner or editor on any site
125
+ * owner → may grant editor on their own site only
126
+ * editor → may not invite
127
+ *
128
+ * `actor` is the caller's already-resolved identity (from requireSiteUser on
129
+ * the anon client). Its role is re-resolved with the service client so a
130
+ * caller can't lie about it.
131
+ */
132
+
133
+ interface InviteInput {
134
+ email: string;
135
+ siteId: string;
136
+ role: "owner" | "editor";
137
+ /** The signed-in user performing the invite. */
138
+ actor: {
139
+ id: string;
140
+ };
141
+ /** Where the invite email's link lands — the site's /admin/auth/callback. */
142
+ redirectTo: string;
143
+ }
144
+ interface InviteResult {
145
+ userId: string;
146
+ siteId: string;
147
+ role: "owner" | "editor";
148
+ /** false when the auth user already existed and only the membership was added. */
149
+ invitedByEmail: boolean;
150
+ }
151
+ declare function inviteUser(ctx: ServiceContext, input: InviteInput): Promise<InviteResult>;
152
+ interface RemoveInput {
153
+ userId: string;
154
+ siteId: string;
155
+ actor: {
156
+ id: string;
157
+ };
158
+ }
159
+ /** Removes the membership only. The auth user survives (they may belong to other sites). */
160
+ declare function removeSiteUser(ctx: ServiceContext, input: RemoveInput): Promise<void>;
161
+
162
+ /** Typed errors so a route can map them to 401 / 403 without string matching. */
163
+ declare class IdentityError extends Error {
164
+ constructor(message: string);
165
+ }
166
+ /** No session — send them to sign in. Maps to 401. */
167
+ declare class UnauthenticatedError extends IdentityError {
168
+ constructor();
169
+ }
170
+ /** Signed in, but not allowed to do this here. Maps to 403. */
171
+ declare class ForbiddenError extends IdentityError {
172
+ readonly siteId: string;
173
+ readonly required: string;
174
+ readonly actual: string | null;
175
+ constructor(siteId: string, required: string, actual: string | null);
176
+ }
177
+
178
+ export { type CallbackOptions, type CallbackResult, type CookieStore, ForbiddenError, type IdentityClient, type IdentityContext, IdentityError, type IdentityUser, type InviteInput, type InviteResult, ROLE_RANK, type RemoveInput, type RequireOptions, type ServiceContext, type SiteMembership, type SiteRole, UnauthenticatedError, createIdentityClient, getCurrentUser, getSiteRole, handleAuthCallback, inviteUser, removeSiteUser, requireSiteUser, roleAtLeast, safeNextPath, signOut };