cloudflare-next-intl 0.7.7 → 0.8.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.
@@ -1,5 +1,6 @@
1
1
  import config from '@intl-config';
2
2
  import reportError from '../../error_handling/report_error';
3
+ import signCustomTokenRemote from './sign_custom_token_remote';
3
4
  // Matches `firebase-admin`'s own `AppCheckTokenGenerator.createCustomToken`
4
5
  // exactly (`token-generator.js`) — this specific audience (the App Check
5
6
  // TOKEN EXCHANGE service, not the App Check API resource name itself) is
@@ -21,36 +22,63 @@ const CUSTOM_TOKEN_LIFETIME = '5m';
21
22
  * Mints a fresh App Check token server-side via a service account, for use
22
23
  * when the client-written App Check cookie (see `appCheckTokenCookieName`)
23
24
  * is absent — e.g. a cold navigation before `AuthUserProvider` has run and
24
- * had a chance to write it. Requires `clientEmail`/`privateKey`/`appId` on
25
- * `firebaseAuth.appCheck`; returns `undefined` (never throws) if the
26
- * exchange fails, so a caller can always fall back to "no App Check token"
27
- * exactly as before this existed.
25
+ * had a chance to write it. Requires `clientEmail`/`appId` on
26
+ * `firebaseAuth.appCheck`, plus either `privateKey` or the
27
+ * `oauthClientId`/`oauthClientSecret`/`oauthRefreshToken` triple; returns
28
+ * `undefined` (never throws) if the exchange fails, so a caller can always
29
+ * fall back to "no App Check token" exactly as before this existed.
28
30
  *
29
- * Signs a short-lived custom JWT with the service account's private key
30
- * (`jose`, Edge/WebCrypto-compatible — no `firebase-admin`), then exchanges
31
- * it for an App Check token via `exchangeCustomToken`, authenticated with
32
- * the project's Web API key (`?key=`) — `exchangeCustomToken` otherwise
33
- * rejects the call outright as an unregistered/unidentified caller
34
- * (403 `PERMISSION_DENIED`), before the custom token itself is even
35
- * evaluated. Not cached beyond the caller's own request-scoped `cache()`
36
- * wrapper — a fresh mint costs one signing operation plus one network
37
- * round-trip, acceptable per-request but not worth doing more than once per
38
- * request.
31
+ * Signs a short-lived custom JWT, then exchanges it for an App Check token
32
+ * via `exchangeCustomToken`, authenticated with the project's Web API key
33
+ * (`?key=`) — `exchangeCustomToken` otherwise rejects the call outright as
34
+ * an unregistered/unidentified caller (403 `PERMISSION_DENIED`), before the
35
+ * custom token itself is even evaluated. Not cached beyond the caller's own
36
+ * request-scoped `cache()` wrapper — a fresh mint costs one signing
37
+ * operation plus one network round-trip, acceptable per-request but not
38
+ * worth doing more than once per request.
39
+ *
40
+ * The custom token is signed one of two ways, `privateKey` taking priority
41
+ * when both are set:
42
+ * - `privateKey` set: signed locally (`jose`, Edge/WebCrypto-compatible —
43
+ * no `firebase-admin`).
44
+ * - OAuth triple set instead: signed remotely via
45
+ * `sign_custom_token_remote.ts` (IAM Credentials `signJwt`) — the way to
46
+ * mint tokens when a GCP org policy blocks creating the service-account
47
+ * key `privateKey` would otherwise require.
39
48
  */
40
49
  export default async function mintServerAppCheckToken(projectId, apiKey, appCheck) {
41
- if (!appCheck?.clientEmail || !appCheck.privateKey || !appCheck.appId)
50
+ if (!appCheck?.clientEmail || !appCheck.appId)
51
+ return undefined;
52
+ const hasOauthTriple = appCheck.oauthClientId && appCheck.oauthClientSecret && appCheck.oauthRefreshToken;
53
+ if (!appCheck.privateKey && !hasOauthTriple)
42
54
  return undefined;
43
55
  try {
44
- const { SignJWT, importPKCS8 } = await import('jose');
45
- const privateKey = await importPKCS8(appCheck.privateKey.replace(/\\n/g, '\n'), 'RS256');
46
- const customToken = await new SignJWT({ app_id: appCheck.appId })
47
- .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
48
- .setIssuer(appCheck.clientEmail)
49
- .setSubject(appCheck.clientEmail)
50
- .setAudience(APP_CHECK_CUSTOM_TOKEN_AUDIENCE)
51
- .setIssuedAt()
52
- .setExpirationTime(CUSTOM_TOKEN_LIFETIME)
53
- .sign(privateKey);
56
+ const claims = {
57
+ iss: appCheck.clientEmail,
58
+ sub: appCheck.clientEmail,
59
+ aud: APP_CHECK_CUSTOM_TOKEN_AUDIENCE,
60
+ iat: Math.floor(Date.now() / 1000),
61
+ exp: Math.floor(Date.now() / 1000) + 300,
62
+ app_id: appCheck.appId,
63
+ };
64
+ const customToken = appCheck.privateKey
65
+ ? await (async () => {
66
+ const { SignJWT, importPKCS8 } = await import('jose');
67
+ const privateKey = await importPKCS8(appCheck.privateKey.replace(/\\n/g, '\n'), 'RS256');
68
+ return new SignJWT({ app_id: appCheck.appId })
69
+ .setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
70
+ .setIssuer(appCheck.clientEmail)
71
+ .setSubject(appCheck.clientEmail)
72
+ .setAudience(APP_CHECK_CUSTOM_TOKEN_AUDIENCE)
73
+ .setIssuedAt()
74
+ .setExpirationTime(CUSTOM_TOKEN_LIFETIME)
75
+ .sign(privateKey);
76
+ })()
77
+ : await signCustomTokenRemote(appCheck.clientEmail, claims, {
78
+ clientId: appCheck.oauthClientId,
79
+ clientSecret: appCheck.oauthClientSecret,
80
+ refreshToken: appCheck.oauthRefreshToken,
81
+ });
54
82
  const url = `https://firebaseappcheck.googleapis.com/v1/projects/${projectId}/apps/${appCheck.appId}:exchangeCustomToken?key=${apiKey}`;
55
83
  const res = await fetch(url, {
56
84
  method: 'POST',
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Signs the App Check custom token claims remotely via IAM Credentials'
3
+ * `signJwt`, instead of locally with a service-account private key. Lets
4
+ * `mintServerAppCheckToken` work under an org policy that enforces
5
+ * `iam.disableServiceAccountKeyCreation` — that constraint blocks
6
+ * `serviceAccounts.keys.create` only; it does not affect `signJwt`, which
7
+ * signs using a key Google holds and never exports.
8
+ *
9
+ * The caller's OAuth identity (the refresh token) must carry
10
+ * `roles/iam.serviceAccountTokenCreator` on `clientEmail` — grant it with:
11
+ * `gcloud iam service-accounts add-iam-policy-binding <clientEmail>
12
+ * --member="user:<you>" --role="roles/iam.serviceAccountTokenCreator"`.
13
+ */
14
+ export default function signCustomTokenRemote(clientEmail: string, claims: Record<string, unknown>, oauth: {
15
+ clientId: string;
16
+ clientSecret: string;
17
+ refreshToken: string;
18
+ }): Promise<string>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Signs the App Check custom token claims remotely via IAM Credentials'
3
+ * `signJwt`, instead of locally with a service-account private key. Lets
4
+ * `mintServerAppCheckToken` work under an org policy that enforces
5
+ * `iam.disableServiceAccountKeyCreation` — that constraint blocks
6
+ * `serviceAccounts.keys.create` only; it does not affect `signJwt`, which
7
+ * signs using a key Google holds and never exports.
8
+ *
9
+ * The caller's OAuth identity (the refresh token) must carry
10
+ * `roles/iam.serviceAccountTokenCreator` on `clientEmail` — grant it with:
11
+ * `gcloud iam service-accounts add-iam-policy-binding <clientEmail>
12
+ * --member="user:<you>" --role="roles/iam.serviceAccountTokenCreator"`.
13
+ */
14
+ export default async function signCustomTokenRemote(clientEmail, claims, oauth) {
15
+ const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
18
+ body: new URLSearchParams({
19
+ client_id: oauth.clientId,
20
+ client_secret: oauth.clientSecret,
21
+ refresh_token: oauth.refreshToken,
22
+ grant_type: 'refresh_token',
23
+ }),
24
+ });
25
+ if (!tokenRes.ok) {
26
+ throw new Error(`oauth2 refresh_token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
27
+ }
28
+ const { access_token: accessToken } = await tokenRes.json();
29
+ const signRes = await fetch(`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${clientEmail}:signJwt`, {
30
+ method: 'POST',
31
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ payload: JSON.stringify(claims) }),
33
+ });
34
+ if (!signRes.ok) {
35
+ throw new Error(`iamcredentials.signJwt failed: ${signRes.status} ${await signRes.text()}`);
36
+ }
37
+ const { signedJwt } = await signRes.json();
38
+ return signedJwt;
39
+ }
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export * from './db';
package/dist/src/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export * from './db';
@@ -85,6 +85,14 @@ export interface RoutingConfig<AppLocales extends Locales, AppLocalePrefixMode e
85
85
  * if this field is missing at call time rather than silently no-op'ing.
86
86
  */
87
87
  firebaseAuth?: FirebaseAuthRoutingConfig;
88
+ /**
89
+ * Configures the optional `db` submodule (Postgres/Drizzle access over a
90
+ * Cloudflare Hyperdrive or plain connection string). Omit entirely to keep
91
+ * it fully disabled — no file in this package imports `pg`/`drizzle-orm`
92
+ * unless a `db` export is actually called, and every such export throws a
93
+ * clear error if this field is missing at call time.
94
+ */
95
+ db?: DbRoutingConfig;
88
96
  /**
89
97
  * Configures the optional `cookie_consent` submodule (cookie-consent +
90
98
  * privacy-policy-update banners). Omit entirely to keep it disabled —
@@ -621,9 +629,10 @@ export interface FirebaseAppCheckConfig {
621
629
  * Service account client email, used ONLY server-side to mint an App
622
630
  * Check token when the client-written App Check cookie is absent (e.g.
623
631
  * a cold navigation before `AuthUserProvider` has run — see
624
- * `appCheckTokenCookieName`). Required alongside `privateKey` and
625
- * `appId` for server-side minting. Never sent to the client — read only
626
- * by `firebase_server.ts`.
632
+ * `appCheckTokenCookieName`). Required alongside `appId`, plus either
633
+ * `privateKey` or the `oauthClientId`/`oauthClientSecret`/
634
+ * `oauthRefreshToken` triple, for server-side minting. Never sent to the
635
+ * client — read only by `firebase_server.ts`.
627
636
  */
628
637
  clientEmail: string;
629
638
  /**
@@ -633,13 +642,35 @@ export interface FirebaseAppCheckConfig {
633
642
  * (e.g. `process.env.FIREBASE_PRIVATE_KEY`), never exposed to the
634
643
  * browser. Escaped `\n` sequences (common when stored in a single-line
635
644
  * env var) are unescaped automatically before use.
636
- */
637
- privateKey: string;
645
+ *
646
+ * Omit this and set the `oauthClientId`/`oauthClientSecret`/
647
+ * `oauthRefreshToken` triple instead when your GCP org enforces
648
+ * `iam.disableServiceAccountKeyCreation`, which blocks issuing this key
649
+ * in the first place. When both are set, `privateKey` takes priority.
650
+ */
651
+ privateKey?: string;
652
+ /**
653
+ * Application Default Credentials OAuth client ID — the `client_id`
654
+ * field from `application_default_credentials.json` (see
655
+ * `gcloud auth application-default login`). Paired with
656
+ * `oauthClientSecret` and `oauthRefreshToken` as an alternative to
657
+ * `privateKey`: instead of signing the App Check custom token locally,
658
+ * it's signed remotely via IAM Credentials `signJwt`, authenticated as
659
+ * this OAuth identity. That identity needs
660
+ * `roles/iam.serviceAccountTokenCreator` on `clientEmail`. Use this when
661
+ * a service-account key can't be created (see `privateKey`). Ignored
662
+ * when `privateKey` is set.
663
+ */
664
+ oauthClientId?: string;
665
+ /** OAuth client secret paired with `oauthClientId`. Same ADC-JSON `client_secret` field, same secret-handling rules as `privateKey`. */
666
+ oauthClientSecret?: string;
667
+ /** OAuth refresh token paired with `oauthClientId`. Same ADC-JSON `refresh_token` field, same secret-handling rules as `privateKey`. */
668
+ oauthRefreshToken?: string;
638
669
  /**
639
670
  * Firebase App Check app ID (e.g. `"1:1234567890:web:abcdef123456"`),
640
- * required alongside `clientEmail`/`privateKey` for server-side minting.
641
- * Distinct from the Firebase Auth `appId` on `FirebaseAuthRoutingConfig`
642
- * itself — App Check registers apps separately.
671
+ * required alongside `clientEmail` for server-side minting. Distinct
672
+ * from the Firebase Auth `appId` on `FirebaseAuthRoutingConfig` itself —
673
+ * App Check registers apps separately.
643
674
  */
644
675
  appId: string;
645
676
  }
@@ -786,3 +817,86 @@ export interface IntlSitemap {
786
817
  lastModified: Date | string | undefined;
787
818
  videos?: Videos[] | undefined;
788
819
  }
820
+ export interface SupabaseDbConfig {
821
+ /**
822
+ * Supabase project URL, e.g. `https://abc.supabase.co`. Defaults to
823
+ * `process.env.NEXT_PUBLIC_SUPABASE_URL`.
824
+ */
825
+ url?: string;
826
+ /**
827
+ * Supabase anon (publishable) key. Defaults to
828
+ * `process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY`. This is the only key the
829
+ * `db` module ever needs — never put a service-role key here.
830
+ */
831
+ anonKey?: string;
832
+ /**
833
+ * Name of the Postgres function that runs the generated SQL. Defaults to
834
+ * `'cfni_exec'` — the function shipped in `supabase/cfni_exec.sql`.
835
+ */
836
+ execFunction?: string;
837
+ }
838
+ export interface DbRoutingConfig {
839
+ /**
840
+ * Postgres connection string. Omit to resolve it from the Cloudflare
841
+ * Hyperdrive binding named by `hyperdriveBinding` instead (the normal
842
+ * production setup); a value here always wins over the binding, which is
843
+ * what makes local dev / build-time evaluation work.
844
+ */
845
+ connectionString?: string;
846
+ /**
847
+ * Name of the Hyperdrive binding on `env` whose `connectionString` is used
848
+ * when `connectionString` is not set. Defaults to `'HYPERDRIVE'`. Requires
849
+ * `generate.getCloudflareContext` to be configured.
850
+ */
851
+ hyperdriveBinding?: string;
852
+ /**
853
+ * Whether the pooled client is closed once the last in-flight
854
+ * `withPublicDb`/`withUserDb` call of the request finishes.
855
+ * Defaults to `true` (one connection per request, released to Hyperdrive
856
+ * immediately). Set `false` to keep the connection open for the lifetime
857
+ * of the isolate — faster for a long-lived server, but it holds a
858
+ * Hyperdrive connection slot between requests.
859
+ */
860
+ disconnectAfterRequest?: boolean;
861
+ /**
862
+ * Postgres role assumed inside `withUserDb`'s transaction. Defaults
863
+ * to `'authenticated'` (the Supabase RLS convention).
864
+ */
865
+ authenticatedRole?: string;
866
+ /**
867
+ * Resolves the user id injected as `request.jwt.claims->>'sub'` inside
868
+ * `withUserDb`. Omit when `firebaseAuth` is configured — the uid then
869
+ * comes from this package's own `getAuthUser()` automatically. Provide it
870
+ * to use a different auth source (or when `firebaseAuth` is absent).
871
+ */
872
+ getUserId?: () => Promise<string | null> | string | null;
873
+ /** Milliseconds `disconnectPostgres` waits for `client.end()` before giving up. Defaults to `2000`. */
874
+ disconnectTimeoutMs?: number;
875
+ /**
876
+ * Reaches Postgres through the Supabase Data API instead of a direct
877
+ * connection, using only your project URL and anon key. Set this when you
878
+ * have no Postgres password to give the package — `withPublicDb` and
879
+ * `withUserDb` behave the same either way, so switching is a config change
880
+ * with no app-code change.
881
+ *
882
+ * Ignored when `connectionString` or `hyperdriveBinding` is set: a direct
883
+ * connection always wins, so adding this block cannot silently reroute
884
+ * live traffic. Requires the `cfni_exec` function from
885
+ * `supabase/cfni_exec.sql` to be installed in your database.
886
+ *
887
+ * No multi-statement transactions: unlike connection-string mode, each
888
+ * statement inside a `withUserDb` callback is its own round-trip. Do not
889
+ * rely on multi-statement atomicity in this mode.
890
+ */
891
+ supabase?: SupabaseDbConfig;
892
+ /**
893
+ * Resolves the JWT sent as `Authorization: Bearer` for `withUserDb` in
894
+ * Supabase mode, which is what makes PostgREST resolve the caller as
895
+ * `authenticated` and apply RLS. Omit when `firebaseAuth` is configured —
896
+ * the signed-in user's Firebase ID token is then used automatically.
897
+ *
898
+ * Unused in connection-string mode, which identifies the user with
899
+ * `getUserId` and `set_config` instead.
900
+ */
901
+ getAccessToken?: () => Promise<string | null> | string | null;
902
+ }
package/llms.txt CHANGED
@@ -23,6 +23,8 @@ other subpath can be used.
23
23
  - `./localeStaticParams` — `generateStaticParams` helper for locale segments.
24
24
  - `./use` — `useLocale`/`useTranslations`; resolves to the RSC or client implementation automatically via the `react-server` export condition. Both throw `"... must be used within an IntlProvider"` if called outside one.
25
25
  - `./ThemeSwitcher` — optional light/dark toggle component.
26
+ - `./db` — `withPublicDb(fn)` / `withUserDb(fn, uid?)` server-side Postgres/Drizzle context helpers (require `db` set on your `RoutingConfig`; direct Postgres or Supabase Data API, see below).
27
+ - `./dbHelpers` — generic Drizzle SQL helper functions (`excluded`, `onConflictSet`, `ago`, `currentDate`, `windowCount`, `unnestLateral`, `ascNullsLast`, `alwaysTrue`, `lateral`, `aliasColumn`, `minOf`, `maxOf`, `roundReal`, `multiply`, `scalarFromCte`) for use with `./db`.
26
28
 
27
29
  ## `firebaseAuth*` subpaths (require `firebaseAuth` set on your `RoutingConfig`)
28
30
 
@@ -44,6 +46,38 @@ other subpath can be used.
44
46
  - `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`). Never renders in local dev (`NODE_ENV === 'development'`) unless `cookieConsent.enableAnalyticsInDevMode` is `true`.
45
47
  - Country-based gating (`cookieConsent.getCountryCode` / `getCloudflareContext` + `gdprCountries`): resolved server-side by `IntlProvider` into a `requiresConsent` boolean passed to `CookieConsentProvider`. Neither getter set → gating off, consent always implicitly granted. `getCountryCode` (direct country resolver) takes precedence over `getCloudflareContext` (reads `cf.country`) when both are set. `getCloudflareContext` accepts `@opennextjs/cloudflare`'s `getCloudflareContext` function directly (its exact overloaded signature — `CookieConsentGetCloudflareContext`), called internally with `{ async: true }`. Unresolved country (or a `null` context) always requires consent (fail-safe). `./cookieConsent` also exports `defaultGdprCountries` (EU/EEA + UK + Switzerland).
46
48
 
49
+ ## `db*` subpaths (require `db` set on your `RoutingConfig`)
50
+
51
+ Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@supabase/supabase-js` ship as dependencies and load via dynamic `import()`, so nothing bundles unless a `db` export is called.
52
+
53
+ - Direct Postgres (wins if configured): `db.connectionString` — Postgres connection string; when omitted, resolved from the Hyperdrive binding named by `db.hyperdriveBinding` (default `'HYPERDRIVE'`) via `generate.getCloudflareContext`. An explicit `connectionString` always wins (enables local dev / build-time evaluation).
54
+ - Supabase Data API (used only when neither of the above is set): `db.supabase` — `{ url?, anonKey?, execFunction? }`, defaulting `url`/`anonKey` to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. Requires installing `supabase/cfni_exec.sql` (a `security invoker` SQL-exec function) in your database. No multi-statement transactions — each statement in a `withUserDb` callback is its own round-trip.
55
+ - `db.disconnectAfterRequest` — direct-Postgres mode only: closes the pooled client once the last in-flight `withPublicDb`/`withUserDb` call of the request finishes. Defaults to `true`; set `false` to keep the connection open for the isolate's lifetime (holds a Hyperdrive slot between requests).
56
+ - `db.authenticatedRole` — direct-Postgres mode only: Postgres role assumed inside `withUserDb`'s transaction. Defaults to `'authenticated'` (Supabase RLS convention).
57
+ - `db.getUserId` — direct-Postgres mode only: resolves the user id injected as `request.jwt.claims->>'sub'` in `withUserDb`. Omit when `firebaseAuth` is configured — the uid is then taken automatically from the signed-in Firebase user via this package's own `getAuthUser()`.
58
+ - `db.getAccessToken` — Supabase mode only: resolves the JWT sent as `Authorization: Bearer` in `withUserDb`, which is what makes PostgREST resolve `authenticated` and apply RLS. Omit when `firebaseAuth` is configured — the signed-in user's Firebase ID token is used automatically.
59
+ - `db.disconnectTimeoutMs` — direct-Postgres mode only: ms `disconnectPostgres` waits for `client.end()` before giving up. Defaults to `2000`.
60
+ - `withPublicDb(fn)` — anonymous role. Direct-Postgres mode: the request's pooled connection, no transaction, no role switch. Supabase mode: the anon key as the PostgREST bearer token. Either way, no user id is attached — RLS keyed on `auth.jwt()` denies access.
61
+ - `withUserDb(fn, uid?)` — signed-in-user role. Direct-Postgres mode: a transaction with `set_config('request.jwt.claims', ...)` + `set local role`, `uid` resolution order explicit arg → `db.getUserId()` → Firebase auth uid → throws. Supabase mode: identity rides on the JWT from `db.getAccessToken`/Firebase instead (`uid` param is ignored), no transaction wraps the call.
62
+ - `./dbHelpers` functions are plain Drizzle `sql`-building utilities with no config dependency — usable standalone.
63
+
64
+ ```typescript
65
+ // src/i18n/intl_config.ts
66
+ import { getCloudflareContext } from "@opennextjs/cloudflare";
67
+
68
+ export default setIntlConfig({
69
+ locales: ["en", "uk"] as const,
70
+ defaultLocale: "en",
71
+ generate: { getCloudflareContext },
72
+ db: { hyperdriveBinding: "HYPERDRIVE" },
73
+ });
74
+
75
+ // anywhere on the server
76
+ import { withPublicDb } from "cloudflare-next-intl/db";
77
+
78
+ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
79
+ ```
80
+
47
81
  ## Conventions
48
82
 
49
83
  - Every exported function/component has a JSDoc comment with an `@example` where usage isn't obvious from the signature alone.
@@ -56,3 +90,4 @@ other subpath can be used.
56
90
  - Missing `firebaseAuth` on `RoutingConfig` throws immediately from any `firebaseAuth*` export, naming the missing config field.
57
91
  - `useLocale`/`useTranslations` (from `./use`) and `useAuthUser` (from `./useFirebaseAuthUser`) resolve to a Server- or Client-Component implementation automatically via the `react-server` export condition — always import from the subpath, never from an internal file path.
58
92
  - No unified `useFirebaseAuthUser` re-export inside `cloudflare-next-intl/firebaseAuth`'s barrel — that barrel can't replicate the `react-server` condition split, so import `cloudflare-next-intl/useFirebaseAuthUser` directly for the hook.
93
+ - Missing `db` on `RoutingConfig` throws immediately from `withPublicDb`/`withUserDb`, naming the missing config field. `pg`, `drizzle-orm`, and `@supabase/supabase-js` ship as dependencies and load via dynamic `import()` — nothing to install yourself, and nothing bundles unless a `db` export is called.
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
-
6
5
  "main": "dist/index.js",
7
6
  "types": "dist/index.d.ts",
8
7
  "sideEffects": false,
8
+ "bin": {
9
+ "cfni-db-codegen": "bin/db_codegen.mjs"
10
+ },
9
11
  "files": [
10
12
  "dist",
13
+ "bin",
14
+ "supabase",
11
15
  "LICENSE",
12
16
  "README.md",
13
17
  "llms.txt"
@@ -172,6 +176,14 @@
172
176
  "./createServerErrorAction": {
173
177
  "types": "./dist/src/error_handling/create_server_error_action.d.ts",
174
178
  "import": "./dist/src/error_handling/create_server_error_action.js"
179
+ },
180
+ "./db": {
181
+ "types": "./dist/src/db/index.d.ts",
182
+ "import": "./dist/src/db/index.js"
183
+ },
184
+ "./dbHelpers": {
185
+ "types": "./dist/src/db/helpers.d.ts",
186
+ "import": "./dist/src/db/helpers.js"
175
187
  }
176
188
  },
177
189
  "scripts": {
@@ -216,8 +228,11 @@
216
228
  "homepage": "https://github.com/demian-ilnytskyi/cloudflare-next-intl#readme",
217
229
  "dependencies": {
218
230
  "@microsoft/clarity": "^1.0.2",
231
+ "@supabase/supabase-js": "^2.112.3",
232
+ "drizzle-orm": "^0.45.2",
219
233
  "firebase": "^12.17.0",
220
- "jose": "^6.2.8"
234
+ "jose": "^6.2.8",
235
+ "pg": "^8.23.0"
221
236
  },
222
237
  "peerDependencies": {
223
238
  "next": ">=12.0.0",
@@ -236,16 +251,18 @@
236
251
  "@testing-library/jest-dom": "^7.0.0",
237
252
  "@testing-library/react": "^16.3.2",
238
253
  "@types/node": "^20.14.5",
254
+ "@types/pg": "^8.23.1",
239
255
  "@types/react": "^19.0.0",
240
256
  "@types/react-dom": "^19.0.0",
241
257
  "@vitest/coverage-v8": "^3.2.7",
258
+ "drizzle-kit": "^0.31.10",
242
259
  "eslint": "^9.28.0",
243
260
  "eslint-config-next": "15.3.3",
244
261
  "eslint-config-prettier": "^10.1.2",
245
262
  "jsdom": "^29.1.1",
246
263
  "next": "^15.3.0",
247
- "react": "^19.0.0",
248
- "react-dom": "^19.0.0",
264
+ "react": "19.2.0",
265
+ "react-dom": "19.2.0",
249
266
  "tsup": "^8.5.1",
250
267
  "typescript": "^5.5.3",
251
268
  "typescript-eslint": "^8.33.1",
@@ -0,0 +1,34 @@
1
+ -- Executes a statement generated by cloudflare-next-intl's `db` module and
2
+ -- returns its rows as positional JSON arrays, which is the shape
3
+ -- drizzle-orm/pg-proxy maps result columns from.
4
+ --
5
+ -- SECURITY INVOKER is load-bearing: the statement runs with the privileges of
6
+ -- the caller (anon or authenticated, per the request's JWT), so row-level
7
+ -- security still applies exactly as it does over the REST API. Do not change
8
+ -- this to SECURITY DEFINER, and do not add a role parameter — either would
9
+ -- turn this into a privilege-escalation primitive.
10
+ create or replace function public.cfni_exec(statement text, params jsonb default '[]'::jsonb)
11
+ returns json
12
+ language plpgsql
13
+ security invoker
14
+ as $$
15
+ declare
16
+ result json;
17
+ args text[];
18
+ begin
19
+ select coalesce(array_agg(value #>> '{}' order by ordinality), '{}')
20
+ into args
21
+ from jsonb_array_elements(params) with ordinality as t(value, ordinality);
22
+
23
+ execute format('select coalesce(json_agg(json_build_array(r.*)), ''[]''::json) from (%s) r', statement)
24
+ into result
25
+ using args;
26
+
27
+ return result;
28
+ end;
29
+ $$;
30
+
31
+ revoke all on function public.cfni_exec(text, jsonb) from public;
32
+ grant execute on function public.cfni_exec(text, jsonb) to authenticated;
33
+ -- Grant to anon only if your app calls withPublicDb:
34
+ grant execute on function public.cfni_exec(text, jsonb) to anon;