@inglorious/create-app 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/bin/index.js +19 -4
- package/bin/lib/app-jsx.js +0 -11
- package/bin/lib/app-lit.js +0 -5
- package/bin/lib/app-vue.js +0 -6
- package/bin/lib/app.js +121 -28
- package/bin/lib/cli.js +2 -2
- package/bin/lib/constants.js +3 -2
- package/bin/lib/features.js +103 -6
- package/bin/lib/firebase.js +177 -0
- package/bin/lib/imports.js +0 -9
- package/bin/lib/mobile.js +0 -5
- package/bin/lib/prompts.js +311 -0
- package/bin/lib/pwa.js +21 -16
- package/bin/lib/questions.js +29 -7
- package/bin/lib/skills.js +11 -2
- package/bin/lib/source.js +4 -11
- package/package.json +1 -1
- package/templates/features/analytics/scripts/analytics-off-native.mjs +31 -0
- package/templates/features/push/public/sw-push.js +23 -0
- package/templates/features/push/scripts/push-vault-secrets.mjs +60 -0
- package/templates/features/push/supabase/functions/deno.json +6 -0
- package/templates/features/push/supabase/functions/push-notify/index.ts +187 -0
- package/templates/features/push/supabase/migrations/push.sql +115 -0
- package/templates/features/push/supabase/seed.sql +20 -0
- package/templates/js/package.json +2 -2
- package/templates/js/vite.config.js +0 -1
- package/templates/js-jsx/package.json +2 -2
- package/templates/js-jsx/vite.config.js +0 -1
- package/templates/js-vue/package.json +2 -2
- package/templates/js-vue/vite.config.js +0 -1
- package/templates/ssx-js/package.json +2 -2
- package/templates/ssx-js/src/site.config.js +0 -1
- package/templates/ssx-js-jsx/package.json +2 -2
- package/templates/ssx-js-jsx/src/site.config.js +0 -1
- package/templates/ssx-js-vue/package.json +2 -2
- package/templates/ssx-js-vue/src/pages/about.js +0 -1
- package/templates/ssx-js-vue/src/pages/index.js +0 -1
- package/templates/ssx-js-vue/src/site.config.js +0 -1
- package/templates/ssx-ts/package.json +2 -2
- package/templates/ssx-ts/src/site.config.ts +0 -1
- package/templates/ssx-ts/types/index.d.ts +0 -7
- package/templates/ssx-ts-jsx/package.json +2 -2
- package/templates/ssx-ts-jsx/src/site.config.ts +0 -1
- package/templates/ssx-ts-jsx/types/index.d.ts +0 -7
- package/templates/ssx-ts-vue/package.json +2 -2
- package/templates/ssx-ts-vue/src/pages/about.ts +0 -1
- package/templates/ssx-ts-vue/src/pages/index.ts +0 -1
- package/templates/ssx-ts-vue/src/site.config.ts +0 -1
- package/templates/ssx-ts-vue/types/index.d.ts +0 -6
- package/templates/ts/package.json +3 -3
- package/templates/ts/types/index.d.ts +0 -7
- package/templates/ts/vite.config.ts +0 -2
- package/templates/ts-jsx/package.json +3 -3
- package/templates/ts-jsx/types/index.d.ts +0 -7
- package/templates/ts-jsx/vite.config.ts +0 -2
- package/templates/ts-vue/package.json +3 -3
- package/templates/ts-vue/types/index.d.ts +0 -6
- package/templates/ts-vue/vite.config.ts +0 -2
package/bin/lib/questions.js
CHANGED
|
@@ -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.
|
|
119
|
-
flags.
|
|
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:
|
|
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({
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
3
|
+
"version": "1.8.1",
|
|
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,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
|
+
$$;
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|