@deverjak/tenantkit-adapter-supabase 0.1.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.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/authz.d.ts +36 -0
- package/dist/authz.js +41 -0
- package/dist/clients.d.ts +9 -0
- package/dist/clients.js +48 -0
- package/dist/database.d.ts +26 -0
- package/dist/database.js +42 -0
- package/dist/env.d.ts +13 -0
- package/dist/env.js +26 -0
- package/dist/identity.d.ts +43 -0
- package/dist/identity.js +86 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime.d.ts +21 -0
- package/dist/runtime.js +28 -0
- package/dist/session.d.ts +14 -0
- package/dist/session.js +63 -0
- package/dist/storage.d.ts +23 -0
- package/dist/storage.js +24 -0
- package/package.json +52 -0
- package/supabase/0000_current_user_id_supabase.sql +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chytré Digital
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# @deverjak/tenantkit-adapter-supabase
|
|
2
|
+
|
|
3
|
+
The **Supabase reference adapter** for the `reservation-core` / tenantkit kernel ports. One call —
|
|
4
|
+
`createSupabaseRuntime()` — gives you a fully‑wired `CoreRuntime` (Database, Identity, Session, Authz,
|
|
5
|
+
Storage) that `withRoute()` consumes. Bring your own **email** (Resend/SMTP) and, if you sell, **payments**
|
|
6
|
+
(Stripe); Supabase covers your DB + auth + storage.
|
|
7
|
+
|
|
8
|
+
> Why Supabase is the *reference* adapter: it implements every flow the kernel needs **natively** — password,
|
|
9
|
+
> magic link, OTP, OAuth, admin user creation, and RLS that reads the caller's JWT — so the mapping is thin and
|
|
10
|
+
> complete. Other adapters (`@deverjak/tenantkit-adapter-postgres` + `@deverjak/tenantkit-adapter-authjs`) implement the same
|
|
11
|
+
> ports; nothing in your app changes when you swap.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @deverjak/tenantkit-kernel @deverjak/tenantkit-adapter-supabase @deverjak/tenantkit-email-resend
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# .env
|
|
21
|
+
SUPABASE_URL=https://YOUR-PROJECT.supabase.co
|
|
22
|
+
SUPABASE_ANON_KEY=eyJ... # publishable / anon key
|
|
23
|
+
SUPABASE_SERVICE_ROLE_KEY=eyJ... # server-only — bypasses RLS, never shipped to the browser
|
|
24
|
+
RESEND_API_KEY=re_...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Wire it (≈12 lines)
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// app/server/runtime.ts
|
|
31
|
+
import { cookies } from 'next/headers'
|
|
32
|
+
import { createSupabaseRuntime } from '@deverjak/tenantkit-adapter-supabase'
|
|
33
|
+
import { createResendEmail } from '@deverjak/tenantkit-email-resend'
|
|
34
|
+
|
|
35
|
+
export const runtime = createSupabaseRuntime({
|
|
36
|
+
email: createResendEmail({ from: 'Acme <no-reply@acme.com>' }),
|
|
37
|
+
// next/headers → the CookieAdapter the SSR client needs
|
|
38
|
+
cookies: async () => {
|
|
39
|
+
const store = await cookies()
|
|
40
|
+
return {
|
|
41
|
+
getAll: () => store.getAll(),
|
|
42
|
+
setAll: (cs) => cs.forEach((c) => store.set(c.name, c.value, c.options)),
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// app/api/courses/route.ts — a real route, RLS-enforced
|
|
50
|
+
import { withRoute, jsonOk } from '@deverjak/tenantkit-kernel'
|
|
51
|
+
import { runtime } from '@/server/runtime'
|
|
52
|
+
|
|
53
|
+
export const POST = withRoute(
|
|
54
|
+
{ runtime, audience: 'staff', minRole: 'coach', can: 'courses:create', tenantFrom: 'cookie', body: CreateCourseSchema },
|
|
55
|
+
async (ctx) => {
|
|
56
|
+
// ctx.db.user() is the caller's RLS-scoped Supabase handle; the INSERT can't escape their tenant.
|
|
57
|
+
const course = await ctx.db.user().rpc('create_course', ctx.input.body)
|
|
58
|
+
return jsonOk({ course })
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. The session cookie carries the user's JWT, PostgREST sets `request.jwt.claims`, and the kernel's
|
|
64
|
+
`core.current_user_id()` resolves automatically — **no `SET LOCAL`, no service key in the request path.**
|
|
65
|
+
|
|
66
|
+
## What each kernel port maps to
|
|
67
|
+
|
|
68
|
+
| Kernel port | Supabase mapping |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `Database.forRequest(req).user()` | cookie‑bound SSR client — RLS **as the caller** |
|
|
71
|
+
| `Database.forRequest(req).anon()` | anon client — public catalogue reads |
|
|
72
|
+
| `Database.forRequest(req).service()` / `Database.service()` | service‑role client — **bypasses RLS** (webhooks/cron) |
|
|
73
|
+
| `ScopedDb.rpc(fn, args)` | `supabase.rpc(...)` — your SECURITY DEFINER functions (e.g. `redeem_credit_into_session`) |
|
|
74
|
+
| `ScopedDb.client` *(escape hatch)* | the raw `SupabaseClient` for idiomatic `.from()` on your own tables |
|
|
75
|
+
| `IdentityProvider.*` | `supabase.auth.*` — password / magic link (via `admin.generateLink`) / OTP / OAuth / `admin.createUser` |
|
|
76
|
+
| `SessionStore.refresh()` | the `updateSession` cookie‑rotation pattern (call it in middleware) |
|
|
77
|
+
| `AuthzStore.*` | service‑role reads of `core.{profiles,memberships,guardianships,plugin_activations,tenants}` keyed by the verified `userId` |
|
|
78
|
+
| `StorageProvider.*` | `supabase.storage.*` |
|
|
79
|
+
|
|
80
|
+
## Database setup (one migration)
|
|
81
|
+
|
|
82
|
+
The kernel ships its schema + RLS. Apply, in order: **(1)** the kernel core migration (creates `core.*`, the
|
|
83
|
+
RLS predicates, `core.current_user_id()`), **(2)** your app/domain migrations, **(3)** *optionally*
|
|
84
|
+
[`./supabase/0000_current_user_id_supabase.sql`](./supabase/0000_current_user_id_supabase.sql) if you want to
|
|
85
|
+
alias `current_user_id()` to Supabase's native `auth.uid()` (Option B in that file). The portable default needs
|
|
86
|
+
no Supabase‑specific SQL at all.
|
|
87
|
+
|
|
88
|
+
**Supabase project settings:**
|
|
89
|
+
- **Project → API → Exposed schemas:** add `core` so the adapter's `.schema('core')` reads work — *or* keep
|
|
90
|
+
`core` private and expose only RPCs (more locked‑down; the adapter then reads via `rpc()`).
|
|
91
|
+
- **Auth → Email:** if you mint magic links with `admin.generateLink()` and send them via Resend (recommended,
|
|
92
|
+
so *you* own the template), disable Supabase's built‑in magic‑link email; or point Supabase SMTP at Resend.
|
|
93
|
+
|
|
94
|
+
## Honest limitations (so nothing surprises you)
|
|
95
|
+
|
|
96
|
+
- **No client‑side transactions.** PostgREST can't `BEGIN…COMMIT` from the client, so `ScopedDb.tx()` runs
|
|
97
|
+
inline. For real atomicity (overbooking guard, credit redeem) call a **SECURITY DEFINER RPC** via `rpc()` —
|
|
98
|
+
which is exactly what the spec does (`redeem_credit_into_session`).
|
|
99
|
+
- **No raw `query()`** on `user`/`anon` scopes (PostgREST, not SQL). Use `.from()` via `ScopedDb.client`, or an
|
|
100
|
+
RPC. Driver adapters (`@deverjak/tenantkit-adapter-postgres`) do implement raw `query()`.
|
|
101
|
+
- **Cookie writing** is the one Next.js‑shaped seam; `@deverjak/tenantkit-next` supplies the `cookies()` factory for
|
|
102
|
+
you. A non‑Next host passes its own `CookieAdapter`.
|
|
103
|
+
- **Service role bypasses RLS** — the adapter fences it to `AuthzStore`/webhooks/cron; your code using
|
|
104
|
+
`Database.service()` must re‑check authorization.
|
|
105
|
+
|
|
106
|
+
## Use it à la carte
|
|
107
|
+
|
|
108
|
+
Don't want the whole runtime? Import a single factory — e.g. keep Supabase for the DB but bring a different
|
|
109
|
+
IdentityProvider:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createSupabaseDatabase, createSupabaseAuthzStore } from '@deverjak/tenantkit-adapter-supabase'
|
|
113
|
+
const runtime = { db: createSupabaseDatabase(), authz: createSupabaseAuthzStore(), identity: myAuthjsIdentity, /* … */ }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
MIT. Part of the tenantkit kernel family — see the platform spec, `docs/14-portability-and-providers.md`.
|
package/dist/authz.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AuthzStore` port → Supabase. The few cross-cutting reads the kernel does itself (profile, memberships,
|
|
3
|
+
* guardianships, plugin activation, tier, provisioning). Backed by the SERVICE-role client and keyed by the
|
|
4
|
+
* already-verified `userId` — reading a user's OWN rows by id is safe regardless of RLS, and sidesteps any
|
|
5
|
+
* membership-policy recursion. Domain queries (courses, sessions, credits) are NOT here — apps run those on
|
|
6
|
+
* the request-scoped `Database.user()` client so RLS applies.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: core/public tables live in the `core`/`public` schemas. `core` must be added to Supabase's
|
|
9
|
+
* "Exposed schemas" (Project → API) for `.schema('core')` to work — or wrap these in RPCs.
|
|
10
|
+
*/
|
|
11
|
+
import type { AuthzStore, ProfileRow } from '@deverjak/tenantkit-kernel';
|
|
12
|
+
export declare class SupabaseAuthzStore implements AuthzStore {
|
|
13
|
+
private db;
|
|
14
|
+
ensureProfile(userId: string, email: string | null): Promise<ProfileRow>;
|
|
15
|
+
getMemberships(userId: string): Promise<Array<{
|
|
16
|
+
tenantId: string;
|
|
17
|
+
role: string;
|
|
18
|
+
}>>;
|
|
19
|
+
getGuardianships(userId: string): Promise<Array<{
|
|
20
|
+
participantId: string;
|
|
21
|
+
tenantId: string;
|
|
22
|
+
relation: string;
|
|
23
|
+
}>>;
|
|
24
|
+
getPluginActivation(tenantId: string, pluginId: string): Promise<{
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
} | null>;
|
|
27
|
+
getTenantTier(tenantId: string): Promise<string>;
|
|
28
|
+
provisionTenant(input: {
|
|
29
|
+
name: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
ownerId: string;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
tenantId: string;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
export declare const createSupabaseAuthzStore: () => SupabaseAuthzStore;
|
package/dist/authz.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { adminClient } from './clients';
|
|
2
|
+
export class SupabaseAuthzStore {
|
|
3
|
+
db = adminClient();
|
|
4
|
+
async ensureProfile(userId, email) {
|
|
5
|
+
const { data } = await this.db.schema('core').from('profiles')
|
|
6
|
+
.select('full_name, locale, avatar_url, phone').eq('id', userId).maybeSingle();
|
|
7
|
+
if (data) {
|
|
8
|
+
return { fullName: data.full_name, locale: data.locale, avatarUrl: data.avatar_url, phone: data.phone };
|
|
9
|
+
}
|
|
10
|
+
const fullName = email ? (email.split('@')[0] ?? null) : null;
|
|
11
|
+
await this.db.schema('core').from('profiles').upsert({ id: userId, full_name: fullName }, { onConflict: 'id', ignoreDuplicates: true });
|
|
12
|
+
return { fullName, locale: null, avatarUrl: null, phone: null };
|
|
13
|
+
}
|
|
14
|
+
async getMemberships(userId) {
|
|
15
|
+
const { data } = await this.db.schema('core').from('memberships').select('tenant_id, role').eq('user_id', userId);
|
|
16
|
+
return (data ?? []).map((m) => ({ tenantId: m.tenant_id, role: m.role }));
|
|
17
|
+
}
|
|
18
|
+
async getGuardianships(userId) {
|
|
19
|
+
const { data } = await this.db.schema('core').from('guardianships')
|
|
20
|
+
.select('participant_id, tenant_id, relation').eq('user_id', userId);
|
|
21
|
+
return (data ?? []).map((g) => ({ participantId: g.participant_id, tenantId: g.tenant_id, relation: g.relation }));
|
|
22
|
+
}
|
|
23
|
+
async getPluginActivation(tenantId, pluginId) {
|
|
24
|
+
const { data } = await this.db.schema('core').from('plugin_activations')
|
|
25
|
+
.select('is_enabled').eq('tenant_id', tenantId).eq('plugin_id', pluginId).maybeSingle();
|
|
26
|
+
return data ? { enabled: data.is_enabled } : null;
|
|
27
|
+
}
|
|
28
|
+
async getTenantTier(tenantId) {
|
|
29
|
+
const { data } = await this.db.schema('core').from('tenants').select('tier').eq('id', tenantId).single();
|
|
30
|
+
return data?.tier ?? 'free';
|
|
31
|
+
}
|
|
32
|
+
async provisionTenant(input) {
|
|
33
|
+
const { data, error } = await this.db.schema('core').rpc('create_tenant_with_owner', {
|
|
34
|
+
p_name: input.name, p_slug: input.slug, p_owner: input.ownerId,
|
|
35
|
+
});
|
|
36
|
+
if (error)
|
|
37
|
+
throw error;
|
|
38
|
+
return { tenantId: data };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const createSupabaseAuthzStore = () => new SupabaseAuthzStore();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { type CookieAdapter, readOnlyCookies } from '@deverjak/tenantkit-kernel';
|
|
3
|
+
export { type CookieAdapter, readOnlyCookies };
|
|
4
|
+
/** Cookie-bound client: every query runs under the caller's RLS identity (JWT from the cookie). */
|
|
5
|
+
export declare function userClient(cookies: CookieAdapter): SupabaseClient;
|
|
6
|
+
/** anon-role client: no session, RLS still enforced. Safe for public reads. */
|
|
7
|
+
export declare function anonClient(): SupabaseClient;
|
|
8
|
+
/** service-role client: BYPASSES RLS. Singleton, server-only. Throws if the service key is absent. */
|
|
9
|
+
export declare function adminClient(): SupabaseClient;
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The raw Supabase client factories — the ONE place @supabase/* is imported. Everything else in this adapter
|
|
3
|
+
* builds on these. Three roles, mirroring the four-factory pattern from the reference apps, here consolidated:
|
|
4
|
+
*
|
|
5
|
+
* • userClient(req) — cookie-bound SSR client; RLS runs AS the signed-in user. PostgREST injects the JWT,
|
|
6
|
+
* so `core.current_user_id()` resolves with ZERO extra work (no SET LOCAL needed).
|
|
7
|
+
* • anonClient() — anon role; RLS applies, no identity (public catalogue reads).
|
|
8
|
+
* • adminClient() — service role; BYPASSES RLS. Server-only singleton. Re-check authz in code.
|
|
9
|
+
*
|
|
10
|
+
* Cookie handling is the only Next.js-specific seam; it is injected so a non-Next host can supply its own.
|
|
11
|
+
*/
|
|
12
|
+
import { createServerClient } from '@supabase/ssr';
|
|
13
|
+
import { createClient } from '@supabase/supabase-js';
|
|
14
|
+
import { supabaseEnv } from './env';
|
|
15
|
+
// CookieAdapter is a VENDOR-FREE kernel type — the adapter (and @deverjak/tenantkit-next) reuse it; neither owns it.
|
|
16
|
+
import { readOnlyCookies } from '@deverjak/tenantkit-kernel';
|
|
17
|
+
export { readOnlyCookies }; // re-export for this adapter's consumers
|
|
18
|
+
/** Cookie-bound client: every query runs under the caller's RLS identity (JWT from the cookie). */
|
|
19
|
+
export function userClient(cookies) {
|
|
20
|
+
const env = supabaseEnv();
|
|
21
|
+
return createServerClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
22
|
+
cookies: {
|
|
23
|
+
getAll: () => cookies.getAll(),
|
|
24
|
+
setAll: (toSet) => cookies.setAll(toSet),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** anon-role client: no session, RLS still enforced. Safe for public reads. */
|
|
29
|
+
export function anonClient() {
|
|
30
|
+
const env = supabaseEnv();
|
|
31
|
+
return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
|
|
32
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
let admin = null;
|
|
36
|
+
/** service-role client: BYPASSES RLS. Singleton, server-only. Throws if the service key is absent. */
|
|
37
|
+
export function adminClient() {
|
|
38
|
+
if (admin)
|
|
39
|
+
return admin;
|
|
40
|
+
const env = supabaseEnv();
|
|
41
|
+
if (!env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
42
|
+
throw new Error('[adapter-supabase] SUPABASE_SERVICE_ROLE_KEY is required for service-role access');
|
|
43
|
+
}
|
|
44
|
+
admin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
|
45
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
46
|
+
});
|
|
47
|
+
return admin;
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Database` port → Supabase. Implements `forRequest(req).{user,anon,service}()` and out-of-band `service()`.
|
|
3
|
+
*
|
|
4
|
+
* The elegant part of the Supabase mapping: `user()` is the cookie-bound client, so RLS runs as the caller
|
|
5
|
+
* with NO `SET LOCAL` — PostgREST injects `request.jwt.claims` and `core.current_user_id()` just works.
|
|
6
|
+
*
|
|
7
|
+
* Honest limitation surfaced by this adapter: PostgREST has no client-side interactive transaction, so `tx()`
|
|
8
|
+
* runs the callback without a real BEGIN/COMMIT. For genuine atomicity (the overbooking guard, credit redeem),
|
|
9
|
+
* the spec already routes through SECURITY DEFINER RPCs (`redeem_credit_into_session`) — call them via `rpc()`.
|
|
10
|
+
* Raw `query()` is intentionally omitted on Supabase scopes (no arbitrary SQL over PostgREST); use `.client`.
|
|
11
|
+
*/
|
|
12
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
13
|
+
import type { Database, RequestDb, ScopedDb } from '@deverjak/tenantkit-kernel';
|
|
14
|
+
/** Concrete ScopedDb that also exposes `.client` — the escape hatch for idiomatic `.from()` on Supabase apps. */
|
|
15
|
+
export declare class SupabaseScopedDb implements ScopedDb {
|
|
16
|
+
readonly client: SupabaseClient;
|
|
17
|
+
constructor(client: SupabaseClient);
|
|
18
|
+
rpc<T = unknown>(fn: string, args: Record<string, unknown>): Promise<T>;
|
|
19
|
+
/** No real transaction over PostgREST — run inline. Use a SECURITY DEFINER RPC when you need atomicity. */
|
|
20
|
+
tx<T>(fn: (db: ScopedDb) => Promise<T>): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
export declare class SupabaseDatabase implements Database {
|
|
23
|
+
forRequest(req: Request): RequestDb;
|
|
24
|
+
service(): ScopedDb;
|
|
25
|
+
}
|
|
26
|
+
export declare const createSupabaseDatabase: () => SupabaseDatabase;
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { adminClient, anonClient, readOnlyCookies, userClient } from './clients';
|
|
2
|
+
/** Concrete ScopedDb that also exposes `.client` — the escape hatch for idiomatic `.from()` on Supabase apps. */
|
|
3
|
+
export class SupabaseScopedDb {
|
|
4
|
+
client;
|
|
5
|
+
constructor(client) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
}
|
|
8
|
+
async rpc(fn, args) {
|
|
9
|
+
const { data, error } = await this.client.rpc(fn, args);
|
|
10
|
+
if (error)
|
|
11
|
+
throw error; // surfaces as PostgrestError → mapped to HTTP by the kernel's jsonError
|
|
12
|
+
return data;
|
|
13
|
+
}
|
|
14
|
+
/** No real transaction over PostgREST — run inline. Use a SECURITY DEFINER RPC when you need atomicity. */
|
|
15
|
+
async tx(fn) {
|
|
16
|
+
return fn(this);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class SupabaseDatabase {
|
|
20
|
+
forRequest(req) {
|
|
21
|
+
const cookies = readOnlyCookies(parseCookieHeader(req.headers.get('cookie')));
|
|
22
|
+
return {
|
|
23
|
+
user: () => new SupabaseScopedDb(userClient(cookies)),
|
|
24
|
+
anon: () => new SupabaseScopedDb(anonClient()),
|
|
25
|
+
service: () => new SupabaseScopedDb(adminClient()),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
service() {
|
|
29
|
+
return new SupabaseScopedDb(adminClient());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const createSupabaseDatabase = () => new SupabaseDatabase();
|
|
33
|
+
function parseCookieHeader(header) {
|
|
34
|
+
if (!header)
|
|
35
|
+
return [];
|
|
36
|
+
return header.split(';').map((pair) => {
|
|
37
|
+
const idx = pair.indexOf('=');
|
|
38
|
+
const name = pair.slice(0, idx).trim();
|
|
39
|
+
const value = decodeURIComponent(pair.slice(idx + 1).trim());
|
|
40
|
+
return { name, value };
|
|
41
|
+
});
|
|
42
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase adapter env — validated once, fail-fast. The publishable key is named per the conventional
|
|
3
|
+
* `SUPABASE_ANON_KEY` (the reference apps used a non-standard name; we normalize it here).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
declare const Schema: z.ZodObject<{
|
|
7
|
+
SUPABASE_URL: z.ZodString;
|
|
8
|
+
SUPABASE_ANON_KEY: z.ZodString;
|
|
9
|
+
SUPABASE_SERVICE_ROLE_KEY: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type SupabaseEnv = z.infer<typeof Schema>;
|
|
12
|
+
export declare function supabaseEnv(): SupabaseEnv;
|
|
13
|
+
export {};
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase adapter env — validated once, fail-fast. The publishable key is named per the conventional
|
|
3
|
+
* `SUPABASE_ANON_KEY` (the reference apps used a non-standard name; we normalize it here).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const Schema = z.object({
|
|
7
|
+
SUPABASE_URL: z.string().url(),
|
|
8
|
+
SUPABASE_ANON_KEY: z.string().min(1),
|
|
9
|
+
/** Server-only. NEVER exposed to the browser bundle. Bypasses RLS. */
|
|
10
|
+
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
|
|
11
|
+
});
|
|
12
|
+
let cached = null;
|
|
13
|
+
export function supabaseEnv() {
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
16
|
+
const parsed = Schema.safeParse({
|
|
17
|
+
SUPABASE_URL: process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
18
|
+
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
19
|
+
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
20
|
+
});
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
throw new Error(`[adapter-supabase] invalid env:\n${parsed.error.issues.map((i) => ` - ${i.path}: ${i.message}`).join('\n')}`);
|
|
23
|
+
}
|
|
24
|
+
cached = parsed.data;
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AuthSession, AuthUser, IdentityProvider } from '@deverjak/tenantkit-kernel';
|
|
2
|
+
import { type CookieAdapter } from './clients';
|
|
3
|
+
export interface SupabaseIdentityDeps {
|
|
4
|
+
/** A writable cookie adapter for the current request (the @deverjak/tenantkit-next binding backs this with next/headers). */
|
|
5
|
+
cookies: () => Promise<CookieAdapter>;
|
|
6
|
+
}
|
|
7
|
+
export declare class SupabaseIdentity implements IdentityProvider {
|
|
8
|
+
private readonly deps;
|
|
9
|
+
constructor(deps: SupabaseIdentityDeps);
|
|
10
|
+
private client;
|
|
11
|
+
getCurrentUser(_req: Request): Promise<AuthUser | null>;
|
|
12
|
+
signInWithPassword(input: {
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
}): Promise<AuthSession>;
|
|
16
|
+
createMagicLink(input: {
|
|
17
|
+
email: string;
|
|
18
|
+
redirectTo: string;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
token: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}>;
|
|
23
|
+
verifyMagicLink(token: string): Promise<AuthSession>;
|
|
24
|
+
requestOtp(email: string): Promise<void>;
|
|
25
|
+
verifyOtp(input: {
|
|
26
|
+
email: string;
|
|
27
|
+
code: string;
|
|
28
|
+
}): Promise<AuthSession>;
|
|
29
|
+
oauthAuthorizeUrl(input: {
|
|
30
|
+
provider: string;
|
|
31
|
+
redirectTo: string;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
oauthExchange(input: {
|
|
34
|
+
provider: string;
|
|
35
|
+
code: string;
|
|
36
|
+
}): Promise<AuthSession>;
|
|
37
|
+
signOut(_req: Request): Promise<void>;
|
|
38
|
+
createUser(input: {
|
|
39
|
+
email: string;
|
|
40
|
+
password?: string;
|
|
41
|
+
}): Promise<AuthUser>;
|
|
42
|
+
}
|
|
43
|
+
export declare const createSupabaseIdentity: (deps: SupabaseIdentityDeps) => SupabaseIdentity;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { adminClient, userClient } from './clients';
|
|
2
|
+
export class SupabaseIdentity {
|
|
3
|
+
deps;
|
|
4
|
+
constructor(deps) {
|
|
5
|
+
this.deps = deps;
|
|
6
|
+
}
|
|
7
|
+
async client() {
|
|
8
|
+
return userClient(await this.deps.cookies());
|
|
9
|
+
}
|
|
10
|
+
async getCurrentUser(_req) {
|
|
11
|
+
const { data } = await (await this.client()).auth.getUser();
|
|
12
|
+
return data.user ? toAuthUser(data.user) : null;
|
|
13
|
+
}
|
|
14
|
+
async signInWithPassword(input) {
|
|
15
|
+
const { data, error } = await (await this.client()).auth.signInWithPassword(input);
|
|
16
|
+
if (error || !data.session)
|
|
17
|
+
throw error ?? new Error('sign-in failed');
|
|
18
|
+
return toAuthSession(data.session);
|
|
19
|
+
}
|
|
20
|
+
async createMagicLink(input) {
|
|
21
|
+
const { data, error } = await adminClient().auth.admin.generateLink({
|
|
22
|
+
type: 'magiclink',
|
|
23
|
+
email: input.email,
|
|
24
|
+
options: { redirectTo: input.redirectTo },
|
|
25
|
+
});
|
|
26
|
+
if (error || !data.properties)
|
|
27
|
+
throw error ?? new Error('generateLink failed');
|
|
28
|
+
return { token: data.properties.hashed_token, url: data.properties.action_link };
|
|
29
|
+
}
|
|
30
|
+
async verifyMagicLink(token) {
|
|
31
|
+
const { data, error } = await (await this.client()).auth.verifyOtp({ token_hash: token, type: 'magiclink' });
|
|
32
|
+
if (error || !data.session)
|
|
33
|
+
throw error ?? new Error('magic-link verify failed');
|
|
34
|
+
return toAuthSession(data.session);
|
|
35
|
+
}
|
|
36
|
+
async requestOtp(email) {
|
|
37
|
+
// Sends a 6-digit code (configure the Supabase email OTP template). Anti-enumeration: never reveals existence.
|
|
38
|
+
await (await this.client()).auth.signInWithOtp({ email, options: { shouldCreateUser: true } });
|
|
39
|
+
}
|
|
40
|
+
async verifyOtp(input) {
|
|
41
|
+
const { data, error } = await (await this.client()).auth.verifyOtp({ email: input.email, token: input.code, type: 'email' });
|
|
42
|
+
if (error || !data.session)
|
|
43
|
+
throw error ?? new Error('otp verify failed');
|
|
44
|
+
return toAuthSession(data.session);
|
|
45
|
+
}
|
|
46
|
+
async oauthAuthorizeUrl(input) {
|
|
47
|
+
const { data, error } = await (await this.client()).auth.signInWithOAuth({
|
|
48
|
+
provider: input.provider,
|
|
49
|
+
options: { redirectTo: input.redirectTo, skipBrowserRedirect: true },
|
|
50
|
+
});
|
|
51
|
+
if (error || !data.url)
|
|
52
|
+
throw error ?? new Error('oauth start failed');
|
|
53
|
+
return data.url;
|
|
54
|
+
}
|
|
55
|
+
async oauthExchange(input) {
|
|
56
|
+
const { data, error } = await (await this.client()).auth.exchangeCodeForSession(input.code);
|
|
57
|
+
if (error || !data.session)
|
|
58
|
+
throw error ?? new Error('oauth exchange failed');
|
|
59
|
+
return toAuthSession(data.session);
|
|
60
|
+
}
|
|
61
|
+
async signOut(_req) {
|
|
62
|
+
await (await this.client()).auth.signOut();
|
|
63
|
+
}
|
|
64
|
+
async createUser(input) {
|
|
65
|
+
const { data, error } = await adminClient().auth.admin.createUser({
|
|
66
|
+
email: input.email,
|
|
67
|
+
password: input.password,
|
|
68
|
+
email_confirm: true,
|
|
69
|
+
});
|
|
70
|
+
if (error || !data.user)
|
|
71
|
+
throw error ?? new Error('createUser failed');
|
|
72
|
+
return toAuthUser(data.user);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export const createSupabaseIdentity = (deps) => new SupabaseIdentity(deps);
|
|
76
|
+
function toAuthUser(u) {
|
|
77
|
+
return { id: u.id, email: u.email ?? null, emailVerified: Boolean(u.email_confirmed_at) };
|
|
78
|
+
}
|
|
79
|
+
function toAuthSession(s) {
|
|
80
|
+
return {
|
|
81
|
+
user: toAuthUser(s.user),
|
|
82
|
+
accessToken: s.access_token,
|
|
83
|
+
refreshToken: s.refresh_token,
|
|
84
|
+
expiresAt: (s.expires_at ?? 0) * 1000,
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deverjak/tenantkit-adapter-supabase — Supabase reference adapter for the kernel ports.
|
|
3
|
+
*
|
|
4
|
+
* Most apps need only `createSupabaseRuntime()`. The individual factories are exported for partial use
|
|
5
|
+
* (e.g. keep Supabase for DB but bring your own IdentityProvider). Provisional scope `@deverjak/tenantkit-*` (ADR-0010).
|
|
6
|
+
*/
|
|
7
|
+
export { createSupabaseRuntime, type SupabaseRuntimeOptions } from './runtime';
|
|
8
|
+
export { createSupabaseDatabase, SupabaseDatabase, SupabaseScopedDb } from './database';
|
|
9
|
+
export { createSupabaseIdentity, SupabaseIdentity, type SupabaseIdentityDeps } from './identity';
|
|
10
|
+
export { createSupabaseSessionStore, SupabaseSessionStore } from './session';
|
|
11
|
+
export { createSupabaseAuthzStore, SupabaseAuthzStore } from './authz';
|
|
12
|
+
export { createSupabaseStorage, SupabaseStorage } from './storage';
|
|
13
|
+
export { type CookieAdapter, userClient, anonClient, adminClient, readOnlyCookies } from './clients';
|
|
14
|
+
export { supabaseEnv, type SupabaseEnv } from './env';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deverjak/tenantkit-adapter-supabase — Supabase reference adapter for the kernel ports.
|
|
3
|
+
*
|
|
4
|
+
* Most apps need only `createSupabaseRuntime()`. The individual factories are exported for partial use
|
|
5
|
+
* (e.g. keep Supabase for DB but bring your own IdentityProvider). Provisional scope `@deverjak/tenantkit-*` (ADR-0010).
|
|
6
|
+
*/
|
|
7
|
+
export { createSupabaseRuntime } from './runtime';
|
|
8
|
+
export { createSupabaseDatabase, SupabaseDatabase, SupabaseScopedDb } from './database';
|
|
9
|
+
export { createSupabaseIdentity, SupabaseIdentity } from './identity';
|
|
10
|
+
export { createSupabaseSessionStore, SupabaseSessionStore } from './session';
|
|
11
|
+
export { createSupabaseAuthzStore, SupabaseAuthzStore } from './authz';
|
|
12
|
+
export { createSupabaseStorage, SupabaseStorage } from './storage';
|
|
13
|
+
export { userClient, anonClient, adminClient, readOnlyCookies } from './clients';
|
|
14
|
+
export { supabaseEnv } from './env';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createSupabaseRuntime()` — the one call that assembles a kernel `CoreRuntime` from Supabase. This is what
|
|
3
|
+
* makes the adapter "drop-in": pass an EmailProvider (and optionally a PaymentProvider/StorageProvider) and you
|
|
4
|
+
* get a fully-wired runtime that `withRoute()` consumes. Email & payments are SEPARATE adapters by design —
|
|
5
|
+
* Supabase is your DB + auth + storage; Resend/Stripe/etc. are yours to choose.
|
|
6
|
+
*/
|
|
7
|
+
import type { CookieAdapter } from './clients';
|
|
8
|
+
import type { Clock, CoreRuntime, EmailProvider, IdGen, PaymentProvider, StorageProvider } from '@deverjak/tenantkit-kernel';
|
|
9
|
+
export interface SupabaseRuntimeOptions {
|
|
10
|
+
/** REQUIRED — transactional email (e.g. @deverjak/tenantkit-email-resend). Supabase doesn't own your templates. */
|
|
11
|
+
email: EmailProvider;
|
|
12
|
+
/** A writable cookie adapter factory for the current request. @deverjak/tenantkit-next supplies one over next/headers. */
|
|
13
|
+
cookies: () => Promise<CookieAdapter>;
|
|
14
|
+
/** OPTIONAL — the payments plugin's provider (e.g. @deverjak/tenantkit-payments-stripe). */
|
|
15
|
+
payments?: PaymentProvider;
|
|
16
|
+
/** OPTIONAL — override storage (defaults to Supabase Storage). */
|
|
17
|
+
storage?: StorageProvider;
|
|
18
|
+
clock?: Clock;
|
|
19
|
+
ids?: IdGen;
|
|
20
|
+
}
|
|
21
|
+
export declare function createSupabaseRuntime(opts: SupabaseRuntimeOptions): CoreRuntime;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createSupabaseDatabase } from './database';
|
|
2
|
+
import { createSupabaseIdentity } from './identity';
|
|
3
|
+
import { createSupabaseSessionStore } from './session';
|
|
4
|
+
import { createSupabaseAuthzStore } from './authz';
|
|
5
|
+
import { createSupabaseStorage } from './storage';
|
|
6
|
+
export function createSupabaseRuntime(opts) {
|
|
7
|
+
return {
|
|
8
|
+
identity: createSupabaseIdentity({ cookies: opts.cookies }),
|
|
9
|
+
sessions: createSupabaseSessionStore(),
|
|
10
|
+
db: createSupabaseDatabase(),
|
|
11
|
+
authz: createSupabaseAuthzStore(),
|
|
12
|
+
email: opts.email,
|
|
13
|
+
payments: opts.payments,
|
|
14
|
+
storage: opts.storage ?? createSupabaseStorage(),
|
|
15
|
+
clock: opts.clock ?? { now: () => new Date() },
|
|
16
|
+
ids: opts.ids ?? defaultIds(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function defaultIds() {
|
|
20
|
+
return {
|
|
21
|
+
uuid: () => crypto.randomUUID(),
|
|
22
|
+
token: (bytes = 32) => {
|
|
23
|
+
const a = new Uint8Array(bytes);
|
|
24
|
+
crypto.getRandomValues(a);
|
|
25
|
+
return Buffer.from(a).toString('base64url');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SessionStore` port → Supabase SSR cookies. `refresh()` is the `updateSession` pattern from the reference
|
|
3
|
+
* apps: build a client whose cookie writes land on the outgoing Response, call `getUser()` to rotate the token,
|
|
4
|
+
* and the SSR client propagates the refreshed `Set-Cookie` headers. The @deverjak/tenantkit-next middleware calls this
|
|
5
|
+
* on every request.
|
|
6
|
+
*/
|
|
7
|
+
import type { AuthSession, SessionStore } from '@deverjak/tenantkit-kernel';
|
|
8
|
+
export declare class SupabaseSessionStore implements SessionStore {
|
|
9
|
+
read(req: Request): Promise<AuthSession | null>;
|
|
10
|
+
write(_res: Response, _session: AuthSession): Promise<void>;
|
|
11
|
+
refresh(req: Request, res: Response): Promise<AuthSession | null>;
|
|
12
|
+
clear(res: Response): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare const createSupabaseSessionStore: () => SupabaseSessionStore;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readOnlyCookies, userClient } from './clients';
|
|
2
|
+
export class SupabaseSessionStore {
|
|
3
|
+
async read(req) {
|
|
4
|
+
const client = userClient(readOnlyCookies(cookiesFromRequest(req)));
|
|
5
|
+
const { data } = await client.auth.getSession();
|
|
6
|
+
return data.session ? toSession(data.session) : null;
|
|
7
|
+
}
|
|
8
|
+
async write(_res, _session) {
|
|
9
|
+
// No-op on Supabase: cookies are written by the SSR client during the auth call that produced the session.
|
|
10
|
+
// Kept for ports that persist out-of-band (e.g. iron-session adapters).
|
|
11
|
+
}
|
|
12
|
+
async refresh(req, res) {
|
|
13
|
+
const cookies = responseCookieAdapter(req, res);
|
|
14
|
+
const client = userClient(cookies);
|
|
15
|
+
const { data } = await client.auth.getUser(); // rotates + sets refreshed cookies onto `res`
|
|
16
|
+
if (!data.user)
|
|
17
|
+
return null;
|
|
18
|
+
const { data: s } = await client.auth.getSession();
|
|
19
|
+
return s.session ? toSession(s.session) : null;
|
|
20
|
+
}
|
|
21
|
+
async clear(res) {
|
|
22
|
+
const cookies = responseCookieAdapter(new Request('http://x'), res);
|
|
23
|
+
await userClient(cookies).auth.signOut();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const createSupabaseSessionStore = () => new SupabaseSessionStore();
|
|
27
|
+
function cookiesFromRequest(req) {
|
|
28
|
+
const header = req.headers.get('cookie');
|
|
29
|
+
if (!header)
|
|
30
|
+
return [];
|
|
31
|
+
return header.split(';').map((p) => {
|
|
32
|
+
const i = p.indexOf('=');
|
|
33
|
+
return { name: p.slice(0, i).trim(), value: decodeURIComponent(p.slice(i + 1).trim()) };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** A cookie adapter that reads from the Request and APPENDS Set-Cookie onto the Response. */
|
|
37
|
+
function responseCookieAdapter(req, res) {
|
|
38
|
+
return {
|
|
39
|
+
getAll: () => cookiesFromRequest(req),
|
|
40
|
+
setAll: (toSet) => {
|
|
41
|
+
for (const c of toSet) {
|
|
42
|
+
res.headers.append('set-cookie', serializeCookie(c.name, c.value, c.options));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function serializeCookie(name, value, options) {
|
|
48
|
+
// Minimal serializer; the @deverjak/tenantkit-next binding uses Next's cookie API in production.
|
|
49
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
50
|
+
if (process.env.NODE_ENV === 'production')
|
|
51
|
+
parts.push('Secure');
|
|
52
|
+
if (options?.['maxAge'])
|
|
53
|
+
parts.push(`Max-Age=${String(options['maxAge'])}`);
|
|
54
|
+
return parts.join('; ');
|
|
55
|
+
}
|
|
56
|
+
function toSession(s) {
|
|
57
|
+
return {
|
|
58
|
+
user: { id: s.user.id, email: s.user.email ?? null, emailVerified: Boolean(s.user.email_confirmed_at) },
|
|
59
|
+
accessToken: s.access_token,
|
|
60
|
+
refreshToken: s.refresh_token,
|
|
61
|
+
expiresAt: (s.expires_at ?? 0) * 1000,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** `StorageProvider` port → Supabase Storage. Optional; used for tenant logos / data exports. */
|
|
2
|
+
import type { StorageProvider } from '@deverjak/tenantkit-kernel';
|
|
3
|
+
export declare class SupabaseStorage implements StorageProvider {
|
|
4
|
+
private db;
|
|
5
|
+
put(input: {
|
|
6
|
+
bucket: string;
|
|
7
|
+
key: string;
|
|
8
|
+
body: ArrayBuffer | Uint8Array;
|
|
9
|
+
contentType: string;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
key: string;
|
|
12
|
+
}>;
|
|
13
|
+
signedUrl(input: {
|
|
14
|
+
bucket: string;
|
|
15
|
+
key: string;
|
|
16
|
+
expiresInSec: number;
|
|
17
|
+
}): Promise<string>;
|
|
18
|
+
remove(input: {
|
|
19
|
+
bucket: string;
|
|
20
|
+
key: string;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare const createSupabaseStorage: () => SupabaseStorage;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { adminClient } from './clients';
|
|
2
|
+
export class SupabaseStorage {
|
|
3
|
+
db = adminClient();
|
|
4
|
+
async put(input) {
|
|
5
|
+
const { error } = await this.db.storage.from(input.bucket).upload(input.key, input.body, {
|
|
6
|
+
contentType: input.contentType, upsert: true,
|
|
7
|
+
});
|
|
8
|
+
if (error)
|
|
9
|
+
throw error;
|
|
10
|
+
return { key: input.key };
|
|
11
|
+
}
|
|
12
|
+
async signedUrl(input) {
|
|
13
|
+
const { data, error } = await this.db.storage.from(input.bucket).createSignedUrl(input.key, input.expiresInSec);
|
|
14
|
+
if (error || !data)
|
|
15
|
+
throw error ?? new Error('signedUrl failed');
|
|
16
|
+
return data.signedUrl;
|
|
17
|
+
}
|
|
18
|
+
async remove(input) {
|
|
19
|
+
const { error } = await this.db.storage.from(input.bucket).remove([input.key]);
|
|
20
|
+
if (error)
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const createSupabaseStorage = () => new SupabaseStorage();
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deverjak/tenantkit-adapter-supabase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Supabase reference adapter for @deverjak/tenantkit-kernel — Database, Identity, Session, Authz, Storage. Drop-in: createSupabaseRuntime() → CoreRuntime.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": "github:chytre-digital/tenantkit-adapter-supabase",
|
|
8
|
+
"homepage": "https://github.com/chytre-digital/tenantkit#readme",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"tenantkit",
|
|
11
|
+
"supabase",
|
|
12
|
+
"multi-tenant",
|
|
13
|
+
"rls",
|
|
14
|
+
"adapter",
|
|
15
|
+
"next"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./sql": "./supabase/0000_current_user_id_supabase.sql"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"supabase"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deverjak/tenantkit-kernel": "^0.1.0",
|
|
33
|
+
"next": ">=15"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@supabase/ssr": "^0.8.0",
|
|
37
|
+
"@supabase/supabase-js": "^2.90.1",
|
|
38
|
+
"zod": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@deverjak/tenantkit-kernel": "^0.1.0",
|
|
42
|
+
"@deverjak/tenantkit-testing": "^0.1.0",
|
|
43
|
+
"@types/node": "^22.10.0",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^4.0.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- @tenantkit/adapter-supabase — Supabase binding for the portable identity seam.
|
|
2
|
+
--
|
|
3
|
+
-- The kernel's RLS predicates call core.current_user_id() (NOT auth.uid()) so the SAME policies run on any
|
|
4
|
+
-- Postgres (see packages/reservation-core/src/db/index.ts + docs/14 §3.1). On SUPABASE you have two options:
|
|
5
|
+
|
|
6
|
+
-- OPTION A (recommended): keep the portable dual-path function shipped by the kernel as-is. It already reads
|
|
7
|
+
-- request.jwt.claims -> 'sub', which PostgREST sets from the user's JWT. Nothing to do here. Most portable.
|
|
8
|
+
|
|
9
|
+
-- OPTION B: if you only ever run on Supabase and prefer the native helper, alias the function to auth.uid():
|
|
10
|
+
create or replace function core.current_user_id()
|
|
11
|
+
returns uuid language sql stable as $$
|
|
12
|
+
select auth.uid()
|
|
13
|
+
$$;
|
|
14
|
+
|
|
15
|
+
-- Either way, apply this AFTER the kernel core migration (which creates the core schema + the predicates).
|
|
16
|
+
--
|
|
17
|
+
-- Supabase project settings reminder:
|
|
18
|
+
-- • Project → API → "Exposed schemas": add `core` (and `public`) so the adapter's .schema('core') reads work,
|
|
19
|
+
-- OR keep core unexposed and reach it only via SECURITY DEFINER RPCs (more locked-down).
|
|
20
|
+
-- • Auth → set the JWT to include `sub` (default). No custom access-token hook is required: memberships are
|
|
21
|
+
-- resolved by the kernel's AuthzStore, not carried in the JWT.
|