@inglorious/create-app 1.7.1 → 1.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.
Files changed (59) hide show
  1. package/README.md +6 -2
  2. package/bin/index.js +19 -4
  3. package/bin/lib/app-jsx.js +0 -11
  4. package/bin/lib/app-lit.js +0 -5
  5. package/bin/lib/app-vue.js +0 -6
  6. package/bin/lib/app.js +121 -28
  7. package/bin/lib/cli.js +2 -2
  8. package/bin/lib/constants.js +3 -2
  9. package/bin/lib/features.js +103 -6
  10. package/bin/lib/firebase.js +177 -0
  11. package/bin/lib/imports.js +0 -9
  12. package/bin/lib/mobile.js +0 -5
  13. package/bin/lib/prompts.js +311 -0
  14. package/bin/lib/pwa.js +21 -16
  15. package/bin/lib/questions.js +29 -7
  16. package/bin/lib/skills.js +11 -2
  17. package/bin/lib/source.js +4 -11
  18. package/package.json +1 -1
  19. package/templates/features/analytics/scripts/analytics-off-native.mjs +31 -0
  20. package/templates/features/push/public/sw-push.js +23 -0
  21. package/templates/features/push/scripts/push-vault-secrets.mjs +60 -0
  22. package/templates/features/push/supabase/functions/deno.json +6 -0
  23. package/templates/features/push/supabase/functions/push-notify/index.ts +187 -0
  24. package/templates/features/push/supabase/migrations/push.sql +115 -0
  25. package/templates/features/push/supabase/seed.sql +20 -0
  26. package/templates/js/package.json +1 -1
  27. package/templates/js/vite.config.js +0 -1
  28. package/templates/js-jsx/package.json +1 -1
  29. package/templates/js-jsx/vite.config.js +0 -1
  30. package/templates/js-vue/package.json +1 -1
  31. package/templates/js-vue/vite.config.js +0 -1
  32. package/templates/ssx-js/package.json +1 -1
  33. package/templates/ssx-js/src/site.config.js +0 -1
  34. package/templates/ssx-js-jsx/package.json +1 -1
  35. package/templates/ssx-js-jsx/src/site.config.js +0 -1
  36. package/templates/ssx-js-vue/package.json +1 -1
  37. package/templates/ssx-js-vue/src/pages/about.js +0 -1
  38. package/templates/ssx-js-vue/src/pages/index.js +0 -1
  39. package/templates/ssx-js-vue/src/site.config.js +0 -1
  40. package/templates/ssx-ts/package.json +1 -1
  41. package/templates/ssx-ts/src/site.config.ts +0 -1
  42. package/templates/ssx-ts/types/index.d.ts +0 -7
  43. package/templates/ssx-ts-jsx/package.json +1 -1
  44. package/templates/ssx-ts-jsx/src/site.config.ts +0 -1
  45. package/templates/ssx-ts-jsx/types/index.d.ts +0 -7
  46. package/templates/ssx-ts-vue/package.json +1 -1
  47. package/templates/ssx-ts-vue/src/pages/about.ts +0 -1
  48. package/templates/ssx-ts-vue/src/pages/index.ts +0 -1
  49. package/templates/ssx-ts-vue/src/site.config.ts +0 -1
  50. package/templates/ssx-ts-vue/types/index.d.ts +0 -6
  51. package/templates/ts/package.json +1 -1
  52. package/templates/ts/types/index.d.ts +0 -7
  53. package/templates/ts/vite.config.ts +0 -2
  54. package/templates/ts-jsx/package.json +1 -1
  55. package/templates/ts-jsx/types/index.d.ts +0 -7
  56. package/templates/ts-jsx/vite.config.ts +0 -2
  57. package/templates/ts-vue/package.json +1 -1
  58. package/templates/ts-vue/types/index.d.ts +0 -6
  59. package/templates/ts-vue/vite.config.ts +0 -2
@@ -27,6 +27,8 @@ export async function askAppFeatures(flags) {
27
27
  transitions: false,
28
28
  icons: [],
29
29
  languages: [],
30
+ analytics: false,
31
+ push: false,
30
32
  pwa: false,
31
33
  }
32
34
 
@@ -115,19 +117,42 @@ export async function askAppFeatures(flags) {
115
117
  if (canceled || !features.languages) return null
116
118
  }
117
119
 
118
- features.pwa =
119
- flags.pwa ??
120
+ features.analytics =
121
+ flags.analytics ??
122
+ (await ask({
123
+ type: "confirm",
124
+ message: "Add analytics with a consent prompt? (Firebase)",
125
+ initial: false,
126
+ }))
127
+ if (canceled) return null
128
+
129
+ features.push =
130
+ flags.push ??
120
131
  (await ask({
121
132
  type: "confirm",
122
- message: "Enable PWA support?",
133
+ message:
134
+ "Add push notifications? (Firebase and Supabase, turns on PWA support)",
123
135
  initial: false,
124
136
  }))
125
137
  if (canceled) return null
126
138
 
139
+ if (features.push && flags.pwa === false) {
140
+ throw new Error("--push needs the service worker that --no-pwa turns off.")
141
+ }
142
+
143
+ features.pwa =
144
+ features.push ||
145
+ (flags.pwa ??
146
+ (await ask({
147
+ type: "confirm",
148
+ message: "Enable PWA support?",
149
+ initial: false,
150
+ })))
151
+ if (canceled) return null
152
+
127
153
  return validate(features)
128
154
  }
129
155
 
130
- // The languages, with the default first.
131
156
  async function askLanguages(ask, isCanceled) {
132
157
  const languages = await ask({
133
158
  type: "multiselect",
@@ -157,7 +182,6 @@ async function askLanguages(ask, isCanceled) {
157
182
  return [defaultLanguage, ...languages.filter((l) => l !== defaultLanguage)]
158
183
  }
159
184
 
160
- // Flags can name things that do not exist, or combine what cannot go together.
161
185
  function validate(features) {
162
186
  if (!THEMES[features.theme]) {
163
187
  throw new Error(
@@ -176,8 +200,6 @@ function validate(features) {
176
200
  `Unknown languages: ${unknownLanguages.join(", ")}. Choose from: ${Object.keys(LANGUAGES).join(", ")}.`,
177
201
  )
178
202
  }
179
- // Transitions animate navigations, with Inglorious UI's stylesheet; icons
180
- // are drawn by its Icon component.
181
203
  if (!features.routing || !features.ui) features.transitions = false
182
204
  if (!features.ui) features.icons = []
183
205
  return features
package/bin/lib/skills.js CHANGED
@@ -5,9 +5,16 @@ const SKILLS_REPOSITORY = "IngloriousCoderz/forge-skills"
5
5
  /**
6
6
  * The agent skills for the packages a new project uses.
7
7
  *
8
- * @param {{ baseTemplate: string, renderer: string, ui: boolean, languages: string[] }} project
8
+ * @param {{ baseTemplate: string, renderer: string, ui: boolean, languages: string[], analytics: boolean, push: boolean }} project
9
9
  */
10
- export function skillsFor({ baseTemplate, renderer, ui, languages }) {
10
+ export function skillsFor({
11
+ baseTemplate,
12
+ renderer,
13
+ ui,
14
+ languages,
15
+ analytics,
16
+ push,
17
+ }) {
11
18
  const skills = [
12
19
  "forge",
13
20
  "forge-best-practices",
@@ -20,6 +27,8 @@ export function skillsFor({ baseTemplate, renderer, ui, languages }) {
20
27
  if (renderer === "vue") skills.push("vite-plugin-vue")
21
28
  if (ui) skills.push("ui", "ui-best-practices")
22
29
  if (languages.length) skills.push("i18n")
30
+ if (analytics || push) skills.push("firebase")
31
+ if (push) skills.push("supabase")
23
32
  return skills
24
33
  }
25
34
 
package/bin/lib/source.js CHANGED
@@ -1,9 +1,5 @@
1
- // What the generated app's components share, whatever their syntax: the pages,
2
- // the icons they show, and helpers that print code the way Prettier would.
3
-
4
1
  import { ICON_PACKS } from "./features.js"
5
2
 
6
- // Prettier's line width, which generated files are written to fit.
7
3
  export const LINE_WIDTH = 80
8
4
 
9
5
  /**
@@ -29,8 +25,6 @@ export function appModel(app) {
29
25
  }
30
26
  }
31
27
 
32
- // `name(args)` on one line when it fits, one argument per line otherwise, as
33
- // Prettier prints a call.
34
28
  export function call(name, args, indent, trailing) {
35
29
  const line = `${indent}${name}(${args.join(", ")})${trailing}`
36
30
  if (line.length <= LINE_WIDTH) return line
@@ -39,16 +33,15 @@ ${args.map((arg) => `${indent} ${arg},`).join("\n")}
39
33
  ${indent})${trailing}`
40
34
  }
41
35
 
42
- // `export type Name = A | B`, broken after the `=` when too long, as Prettier
43
- // prints it.
44
36
  export function union(name, members) {
45
37
  const line = `export type ${name} = ${members.join(" | ")}`
46
38
  if (line.length <= LINE_WIDTH) return line
47
- return `export type ${name} =
48
- ${members.join(" | ")}`
39
+ const secondLine = ` ${members.join(" | ")}`
40
+ if (secondLine.length <= LINE_WIDTH)
41
+ return `export type ${name} =\n${secondLine}`
42
+ return `export type ${name} =\n${members.map((member) => ` | ${member}`).join("\n")}`
49
43
  }
50
44
 
51
- // Indents every non-empty line of a block.
52
45
  export function indent(block, spaces) {
53
46
  const pad = " ".repeat(spaces)
54
47
  return block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/create-app",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "A scaffolding tool to quickly create a new app with Inglorious Web.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -0,0 +1,31 @@
1
+ // Runs after every `cap sync`: `cap add` creates the native projects, and they
2
+ // must start with Analytics off, or they measure from launch, before anyone
3
+ // agreed. createAnalytics turns it on once the user says yes.
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
6
+
7
+ insertBefore(
8
+ "android/app/src/main/AndroidManifest.xml",
9
+ "</application>",
10
+ ` <meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />
11
+ <meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
12
+ `,
13
+ )
14
+
15
+ insertBefore(
16
+ "ios/App/App/Info.plist",
17
+ "</dict>\n</plist>",
18
+ ` <key>FIREBASE_ANALYTICS_COLLECTION_ENABLED</key>
19
+ <false/>
20
+ <key>GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED</key>
21
+ <false/>
22
+ `,
23
+ )
24
+
25
+ function insertBefore(file, closing, settings) {
26
+ if (!existsSync(file)) return
27
+ const source = readFileSync(file, "utf-8")
28
+ if (source.includes("ANALYTICS_COLLECTION_ENABLED")) return
29
+ if (source.includes("firebase_analytics_collection_enabled")) return
30
+ writeFileSync(file, source.replace(closing, `${settings}${closing}`))
31
+ }
@@ -0,0 +1,23 @@
1
+ self.addEventListener("push", (event) => {
2
+ const { title, body, url } = event.data.json().data
3
+ event.waitUntil(
4
+ self.registration.showNotification(title, {
5
+ body,
6
+ icon: "/pwa-192x192.png",
7
+ data: { url },
8
+ }),
9
+ )
10
+ })
11
+
12
+ self.addEventListener("notificationclick", (event) => {
13
+ event.notification.close()
14
+ const url = new URL(event.notification.data.url, self.location.origin).href
15
+ event.waitUntil(open(url))
16
+ })
17
+
18
+ async function open(url) {
19
+ const [client] = await self.clients.matchAll({ type: "window" })
20
+ if (!client) return self.clients.openWindow(url)
21
+ await client.focus()
22
+ if (client.url !== url) await client.navigate(url)
23
+ }
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import process from "node:process"
6
+
7
+ const ref = readFileSync("supabase/.temp/project-ref", "utf8").trim()
8
+
9
+ const keys = JSON.parse(
10
+ execFileSync(
11
+ "supabase",
12
+ ["projects", "api-keys", "--project-ref", ref, "-o", "json"],
13
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
14
+ ),
15
+ )
16
+ const serviceRoleKey = keys.find(
17
+ ({ name, type }) => name === "service_role" && type === "legacy",
18
+ )?.api_key
19
+ if (!serviceRoleKey) throw new Error("No legacy service_role key found")
20
+
21
+ const secrets = {
22
+ project_url: `https://${ref}.supabase.co`,
23
+ service_role_key: serviceRoleKey,
24
+ }
25
+
26
+ const sql = `do $$
27
+ declare
28
+ v_id uuid;
29
+ begin
30
+ ${Object.entries(secrets)
31
+ .map(
32
+ ([
33
+ name,
34
+ value,
35
+ ]) => ` select id into v_id from vault.secrets where name = '${name}';
36
+ if v_id is null then
37
+ perform vault.create_secret('${value}', '${name}');
38
+ else
39
+ perform vault.update_secret(v_id, '${value}');
40
+ end if;`,
41
+ )
42
+ .join("\n")}
43
+ end;
44
+ $$;
45
+ `
46
+
47
+ const dir = mkdtempSync(path.join(tmpdir(), "vaitu-vault-"))
48
+ const file = path.join(dir, "secrets.sql")
49
+ try {
50
+ writeFileSync(file, sql, { mode: 0o600 })
51
+ execFileSync("supabase", ["db", "query", "--linked", "--file", file], {
52
+ stdio: ["ignore", "ignore", "inherit"],
53
+ })
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true })
56
+ }
57
+
58
+ process.stdout.write(
59
+ `Vault set for ${ref}: ${Object.keys(secrets).join(", ")}\n`,
60
+ )
@@ -0,0 +1,6 @@
1
+ {
2
+ "nodeModulesDir": "none",
3
+ "lint": {
4
+ "rules": { "exclude": ["no-import-prefix"] }
5
+ }
6
+ }
@@ -0,0 +1,187 @@
1
+ import "jsr:@supabase/functions-js@2/edge-runtime.d.ts"
2
+
3
+ import { decodeBase64 } from "jsr:@std/encoding@1/base64"
4
+ import { decodeBase64Url, encodeBase64Url } from "jsr:@std/encoding@1/base64url"
5
+ import { createClient } from "npm:@supabase/supabase-js@2"
6
+
7
+ interface ServiceAccount {
8
+ project_id: string
9
+ client_email: string
10
+ private_key: string
11
+ }
12
+
13
+ const TOKEN_URL = "https://oauth2.googleapis.com/token"
14
+ const SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
15
+ const MS_PER_SECOND = 1000
16
+ const TOKEN_LIFETIME_S = 3600
17
+ const RENEW_MARGIN_S = 60
18
+ const STATUS_FORBIDDEN = 403
19
+ const STATUS_BAD_REQUEST = 400
20
+ const STATUS_NOT_FOUND = 404
21
+
22
+ let access: { token: string; expiresAt: number } | null = null
23
+
24
+ Deno.serve(async (req) => {
25
+ if (callerRole(req) !== "service_role") {
26
+ return Response.json(
27
+ { error: "Database only" },
28
+ { status: STATUS_FORBIDDEN },
29
+ )
30
+ }
31
+
32
+ const { notification_id: id } = await req.json()
33
+ if (typeof id !== "string") {
34
+ return Response.json(
35
+ { error: "notification_id required" },
36
+ { status: STATUS_BAD_REQUEST },
37
+ )
38
+ }
39
+
40
+ const db = createClient(env("SUPABASE_URL"), env("SUPABASE_SERVICE_ROLE_KEY"))
41
+
42
+ // Setting pushed_at is the claim: a repeated call finds nothing left to send.
43
+ const { data: notification, error } = await db
44
+ .from("notifications")
45
+ .update({ pushed_at: new Date().toISOString() })
46
+ .eq("id", id)
47
+ .is("pushed_at", null)
48
+ .select("user_id, title, body, url")
49
+ .maybeSingle()
50
+ if (error) throw error
51
+ if (!notification) return Response.json({ sent: 0 })
52
+
53
+ const { data: devices, error: devicesError } = await db
54
+ .from("push_tokens")
55
+ .select("token, platform")
56
+ .eq("user_id", notification.user_id)
57
+ if (devicesError) throw devicesError
58
+
59
+ const responses = await Promise.all(
60
+ devices.map(({ token, platform }) =>
61
+ send(fcmMessage(token, platform, notification)),
62
+ ),
63
+ )
64
+
65
+ // 404: the app was uninstalled, the browser reset or the permission revoked.
66
+ const gone = devices
67
+ .filter((_, index) => responses[index].status === STATUS_NOT_FOUND)
68
+ .map(({ token }) => token)
69
+ if (gone.length) await db.from("push_tokens").delete().in("token", gone)
70
+
71
+ for (const response of responses) {
72
+ if (!response.ok && response.status !== STATUS_NOT_FOUND) {
73
+ console.error("push failed", response.status, await response.text())
74
+ }
75
+ }
76
+
77
+ return Response.json({
78
+ sent: responses.filter((response) => response.ok).length,
79
+ })
80
+ })
81
+
82
+ // Browsers get data only, for public/sw-push.js to show. The native apps get a
83
+ // notification the system shows by itself, even with the app closed.
84
+ function fcmMessage(
85
+ token: string,
86
+ platform: string,
87
+ { title, body, url }: { title: string; body: string; url: string },
88
+ ) {
89
+ if (platform === "web") {
90
+ return {
91
+ token,
92
+ data: { title, body, url },
93
+ webpush: { headers: { Urgency: "high" } },
94
+ }
95
+ }
96
+ return {
97
+ token,
98
+ notification: { title, body },
99
+ data: { url },
100
+ android: { priority: "high" },
101
+ }
102
+ }
103
+
104
+ async function send(message: unknown) {
105
+ const account: ServiceAccount = JSON.parse(env("FCM_SERVICE_ACCOUNT"))
106
+ return fetch(
107
+ `https://fcm.googleapis.com/v1/projects/${account.project_id}/messages:send`,
108
+ {
109
+ method: "POST",
110
+ headers: {
111
+ Authorization: `Bearer ${await accessToken(account)}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify({ message }),
115
+ },
116
+ )
117
+ }
118
+
119
+ async function accessToken(account: ServiceAccount) {
120
+ const now = Math.floor(Date.now() / MS_PER_SECOND)
121
+ if (access && access.expiresAt - RENEW_MARGIN_S > now) return access.token
122
+
123
+ const res = await fetch(TOKEN_URL, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: new URLSearchParams({
127
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
128
+ assertion: await signedJwt(account, now),
129
+ }),
130
+ })
131
+ if (!res.ok)
132
+ throw new Error(`Google OAuth ${res.status}: ${await res.text()}`)
133
+ const { access_token, expires_in } = await res.json()
134
+ access = { token: access_token, expiresAt: now + expires_in }
135
+ return access_token
136
+ }
137
+
138
+ async function signedJwt(account: ServiceAccount, now: number) {
139
+ const header = jwtPart({ alg: "RS256", typ: "JWT" })
140
+ const claims = jwtPart({
141
+ iss: account.client_email,
142
+ scope: SCOPE,
143
+ aud: TOKEN_URL,
144
+ iat: now,
145
+ exp: now + TOKEN_LIFETIME_S,
146
+ })
147
+ const key = await crypto.subtle.importKey(
148
+ "pkcs8",
149
+ decodeBase64(
150
+ account.private_key.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""),
151
+ ),
152
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
153
+ false,
154
+ ["sign"],
155
+ )
156
+ const signature = await crypto.subtle.sign(
157
+ "RSASSA-PKCS1-v1_5",
158
+ key,
159
+ new TextEncoder().encode(`${header}.${claims}`),
160
+ )
161
+ return `${header}.${claims}.${encodeBase64Url(signature)}`
162
+ }
163
+
164
+ function jwtPart(value: unknown) {
165
+ return encodeBase64Url(new TextEncoder().encode(JSON.stringify(value)))
166
+ }
167
+
168
+ // The gateway has already verified the token's signature; its role says whether
169
+ // the database sent it or a client holding the publishable key.
170
+ function callerRole(req: Request) {
171
+ const token = (req.headers.get("authorization") ?? "").replace(
172
+ /^Bearer\s+/i,
173
+ "",
174
+ )
175
+ try {
176
+ const payload = decodeBase64Url(token.split(".")[1])
177
+ return JSON.parse(new TextDecoder().decode(payload)).role
178
+ } catch {
179
+ return null
180
+ }
181
+ }
182
+
183
+ function env(name: string) {
184
+ const value = Deno.env.get(name)
185
+ if (!value) throw new Error(`${name} is not set`)
186
+ return value
187
+ }
@@ -0,0 +1,115 @@
1
+ create extension if not exists pg_net with schema extensions;
2
+
3
+ -- A token belongs to one user at a time, whoever last signed in on that device,
4
+ -- so two people sharing a phone never both get pushes on it.
5
+ create table public.push_tokens (
6
+ token text primary key,
7
+ user_id uuid not null references auth.users (id) on delete cascade,
8
+ platform text not null check (platform in ('web', 'android', 'ios')),
9
+ created_at timestamptz not null default now()
10
+ );
11
+
12
+ create index push_tokens_user_id_idx on public.push_tokens (user_id);
13
+
14
+ alter table public.push_tokens enable row level security;
15
+
16
+ create policy "Users can read their own push tokens"
17
+ on public.push_tokens for select to authenticated
18
+ using (auth.uid() = user_id);
19
+
20
+ create policy "Users can delete their own push tokens"
21
+ on public.push_tokens for delete to authenticated
22
+ using (auth.uid() = user_id);
23
+
24
+ -- Security definer: the token may still be held by the device's previous user
25
+ -- (a sign-out that could not reach the server), and RLS would hide that row
26
+ -- from the one taking it over.
27
+ create function public.save_push_token(p_token text, p_platform text)
28
+ returns void
29
+ language plpgsql
30
+ security definer
31
+ set search_path = public
32
+ as $$
33
+ begin
34
+ if auth.uid() is null then
35
+ raise exception 'Not logged in';
36
+ end if;
37
+
38
+ insert into public.push_tokens (token, user_id, platform)
39
+ values (p_token, auth.uid(), p_platform)
40
+ on conflict (token) do update
41
+ set user_id = excluded.user_id,
42
+ platform = excluded.platform;
43
+ end;
44
+ $$;
45
+
46
+ revoke execute on function public.save_push_token(text, text) from public, anon;
47
+ grant execute on function public.save_push_token(text, text) to authenticated;
48
+
49
+ -- Inserting a row is how the app sends a push: from a trigger, an edge function
50
+ -- or the dashboard. No insert policy, so clients cannot send pushes to others.
51
+ create table public.notifications (
52
+ id uuid primary key default gen_random_uuid(),
53
+ user_id uuid not null references auth.users (id) on delete cascade,
54
+ title text not null,
55
+ body text not null,
56
+ url text not null default '/',
57
+ created_at timestamptz not null default now(),
58
+ pushed_at timestamptz
59
+ );
60
+
61
+ create index notifications_user_id_idx on public.notifications (user_id);
62
+
63
+ alter table public.notifications enable row level security;
64
+
65
+ create policy "Users can read their own notifications"
66
+ on public.notifications for select to authenticated
67
+ using (auth.uid() = user_id);
68
+
69
+ -- The URL and the service-role key live in Vault, so neither is in a migration:
70
+ -- supabase/seed.sql sets the local ones, `pnpm vault:push` the linked project's.
71
+ create function public.wake_push_notify()
72
+ returns trigger
73
+ language plpgsql
74
+ security definer
75
+ set search_path = public
76
+ as $$
77
+ declare
78
+ v_url text;
79
+ v_key text;
80
+ begin
81
+ if not exists (
82
+ select 1 from public.push_tokens where user_id = new.user_id
83
+ ) then
84
+ return new;
85
+ end if;
86
+
87
+ select decrypted_secret into v_url
88
+ from vault.decrypted_secrets where name = 'project_url';
89
+ select decrypted_secret into v_key
90
+ from vault.decrypted_secrets where name = 'service_role_key';
91
+ if v_url is null or v_key is null then
92
+ return new;
93
+ end if;
94
+
95
+ perform net.http_post(
96
+ url := rtrim(v_url, '/') || '/functions/v1/push-notify',
97
+ headers := jsonb_build_object(
98
+ 'Content-Type', 'application/json',
99
+ 'Authorization', 'Bearer ' || v_key
100
+ ),
101
+ body := jsonb_build_object('notification_id', new.id)
102
+ );
103
+ return new;
104
+ exception when others then
105
+ -- A failed push must never fail the insert that asked for it.
106
+ raise warning 'wake_push_notify: %', sqlerrm;
107
+ return new;
108
+ end;
109
+ $$;
110
+
111
+ revoke execute on function public.wake_push_notify() from public, anon, authenticated;
112
+
113
+ create trigger wake_push_notify
114
+ after insert on public.notifications
115
+ for each row execute function public.wake_push_notify();
@@ -0,0 +1,20 @@
1
+ -- What the push trigger calls push-notify with on the local stack: its gateway,
2
+ -- as the database container sees it, and the demo key every local stack shares.
3
+ -- Applied by `supabase db reset`, so a linked reset takes `--no-seed`.
4
+ do $$
5
+ begin
6
+ if not exists (
7
+ select 1 from vault.decrypted_secrets where name = 'project_url'
8
+ ) then
9
+ perform vault.create_secret('http://kong:8000', 'project_url');
10
+ end if;
11
+ if not exists (
12
+ select 1 from vault.decrypted_secrets where name = 'service_role_key'
13
+ ) then
14
+ perform vault.create_secret(
15
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU',
16
+ 'service_role_key'
17
+ );
18
+ end if;
19
+ end;
20
+ $$;
@@ -30,6 +30,6 @@
30
30
  },
31
31
  "packageManager": "pnpm@10.15.0",
32
32
  "engines": {
33
- "node": ">= 22"
33
+ "node": ">= 26"
34
34
  }
35
35
  }
@@ -9,7 +9,6 @@ import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
9
9
  const __filename = fileURLToPath(import.meta.url)
10
10
  const __dirname = path.dirname(__filename)
11
11
 
12
- // https://vite.dev/config/
13
12
  export default defineConfig({
14
13
  plugins: [ViteImageOptimizer(), hmr()],
15
14
 
@@ -32,6 +32,6 @@
32
32
  },
33
33
  "packageManager": "pnpm@10.15.0",
34
34
  "engines": {
35
- "node": ">= 22"
35
+ "node": ">= 26"
36
36
  }
37
37
  }
@@ -10,7 +10,6 @@ import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
10
10
  const __filename = fileURLToPath(import.meta.url)
11
11
  const __dirname = path.dirname(__filename)
12
12
 
13
- // https://vite.dev/config/
14
13
  export default defineConfig({
15
14
  plugins: [ViteImageOptimizer(), jsx(), hmr()],
16
15
 
@@ -31,6 +31,6 @@
31
31
  },
32
32
  "packageManager": "pnpm@10.15.0",
33
33
  "engines": {
34
- "node": ">= 22"
34
+ "node": ">= 26"
35
35
  }
36
36
  }
@@ -10,7 +10,6 @@ import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
10
10
  const __filename = fileURLToPath(import.meta.url)
11
11
  const __dirname = path.dirname(__filename)
12
12
 
13
- // https://vite.dev/config/
14
13
  export default defineConfig({
15
14
  plugins: [ViteImageOptimizer(), vue(), hmr()],
16
15
 
@@ -32,6 +32,6 @@
32
32
  },
33
33
  "packageManager": "pnpm@10.15.0",
34
34
  "engines": {
35
- "node": ">= 22"
35
+ "node": ">= 26"
36
36
  }
37
37
  }
@@ -6,7 +6,6 @@ export default {
6
6
  description: "Built with @inglorious/ssx",
7
7
  viewport: "width=device-width, initial-scale=1.0, viewport-fit=cover",
8
8
  },
9
- // SSX includes image optimization by default
10
9
  vite: {
11
10
  server: {
12
11
  port: 3000,
@@ -34,6 +34,6 @@
34
34
  },
35
35
  "packageManager": "pnpm@10.15.0",
36
36
  "engines": {
37
- "node": ">= 22"
37
+ "node": ">= 26"
38
38
  }
39
39
  }