Compare commits
10 Commits
73e4c56a9e
...
9aecbfbbf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aecbfbbf9 | ||
|
|
7c755740b9 | ||
|
|
b56577ddc2 | ||
|
|
53349ca530 | ||
|
|
d32c108f23 | ||
|
|
0745ad14f0 | ||
|
|
ea7384591c | ||
|
|
5016c5cd47 | ||
|
|
9caebca20d | ||
|
|
82ac540909 |
29
.env.example
29
.env.example
@@ -1,5 +1,5 @@
|
||||
# App
|
||||
NUXT_SESSION_SECRET=change-me
|
||||
# App — session sealing key for nuxt-auth-utils (must be ≥32 chars)
|
||||
NUXT_SESSION_PASSWORD=change-me-to-a-long-random-string-min-32-chars
|
||||
|
||||
# Database (Postgres) — Coolify-managed in prod, docker-compose in dev
|
||||
DATABASE_URL=postgres://andon:andon@localhost:5432/andon
|
||||
@@ -9,12 +9,21 @@ POSTGRES_USER=andon
|
||||
POSTGRES_PASSWORD=andon
|
||||
POSTGRES_DB=andon
|
||||
|
||||
# Google OAuth (Task 9) — restricted to the theodo.com hosted domain
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=
|
||||
# Google OAuth (Task 9) — nuxt-auth-utils reads these exact names. Access is
|
||||
# restricted server-side to verified @theodo.com emails (the `hd` claim is not
|
||||
# trusted on its own). REDIRECT_URL must match an Authorized redirect URI in the
|
||||
# Google Cloud console (defaults to <origin>/auth/google/callback if unset).
|
||||
NUXT_OAUTH_GOOGLE_CLIENT_ID=
|
||||
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=
|
||||
NUXT_OAUTH_GOOGLE_REDIRECT_URL=
|
||||
|
||||
# Web Push / VAPID (Task 10)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:example@theodo.com
|
||||
# Web Push / VAPID (Task 10) — Nuxt binds these to runtimeConfig at runtime.
|
||||
# Generate the key pair with `pnpm vapid:keys`. The public key is sent to the
|
||||
# browser; the private key signs pushes server-side (T11) and must stay secret.
|
||||
NUXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||
NUXT_VAPID_PRIVATE_KEY=
|
||||
NUXT_VAPID_SUBJECT=mailto:example@theodo.com
|
||||
|
||||
# Push notifications go to this single owner only (G3). Not secret — also gates
|
||||
# the client-side "Enable notifications" button.
|
||||
NUXT_PUBLIC_OWNER_EMAIL=
|
||||
|
||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
32
README.md
32
README.md
@@ -61,9 +61,37 @@ migrate step is needed.
|
||||
1. Point Coolify at this repo; it builds the production `Dockerfile`.
|
||||
2. Create a **managed Postgres** in Coolify (automatic backups — [ADR 0003](./docs/adr/0003-use-coolify-managed-postgres-over-sqlite.md))
|
||||
and set `DATABASE_URL` to it. _(Alternatively deploy `docker-compose.yml`, which bundles a Postgres service.)_
|
||||
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_SECRET`,
|
||||
Google OAuth, VAPID) and route `andon.apoena.dev` to the service.
|
||||
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_PASSWORD`,
|
||||
`NUXT_OAUTH_GOOGLE_*`, `NUXT_*VAPID*`, `NUXT_PUBLIC_OWNER_EMAIL`) and route
|
||||
`andon.apoena.dev` to the service. Add
|
||||
`https://andon.apoena.dev/auth/google/callback` as an Authorized redirect URI
|
||||
in the Google Cloud OAuth client.
|
||||
4. **Enable Coolify's autodeploy** so it builds and deploys on every push to
|
||||
`main`. Protect `main` with required PR review + CI so only vetted commits
|
||||
reach production.
|
||||
5. The OAuth `hd` claim is spoofable — the app re-checks the email domain server-side (T9).
|
||||
|
||||
### Secrets
|
||||
|
||||
Mark the secret vars (`NUXT_SESSION_PASSWORD`, `NUXT_OAUTH_GOOGLE_CLIENT_SECRET`,
|
||||
`NUXT_VAPID_PRIVATE_KEY`, the Postgres password) as **runtime-only** in Coolify —
|
||||
**not** build-time variables. The app reads them from runtime config at boot, so
|
||||
the build never needs them. Marking them as build variables passes them as Docker
|
||||
`ARG`s, which leaks them into build logs and image layers (Docker warns
|
||||
`SecretsUsedInArgOrEnv`). Only `NUXT_PUBLIC_*` values are non-secret. If a secret
|
||||
is ever exposed, **rotate it** and redeploy (rotating `NUXT_SESSION_PASSWORD`
|
||||
invalidates all existing sessions).
|
||||
|
||||
### Troubleshooting: a deploy that looks stale
|
||||
|
||||
If the live site shows old UI after a successful deploy, check in this order —
|
||||
it's almost never the build:
|
||||
|
||||
1. **Confirm what's actually live.** `git log -1 origin/main` is the source of
|
||||
truth; a local-only commit can't deploy. The Coolify deploy log shows the
|
||||
built commit, a non-cached `RUN pnpm build`, and a healthy rolling update.
|
||||
2. **Client cache.** Hard-refresh (`Cmd+Shift+R`) or open a private window. A
|
||||
browser-cached HTML document referencing old chunk hashes is the usual cause.
|
||||
3. **CDN/proxy cache.** If a CDN (e.g. Cloudflare) fronts the domain, purge it.
|
||||
4. **Build cache (last resort).** Redeploy in Coolify with cache disabled / force
|
||||
rebuild.
|
||||
|
||||
116
app/components/EnableNotifications.vue
Normal file
116
app/components/EnableNotifications.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
// Owner-only Web Push enrolment (T10/F8). Renders nothing unless the signed-in
|
||||
// user is the configured owner. Registers the service worker, subscribes the
|
||||
// device with the public VAPID key, and stores the subscription server-side;
|
||||
// turning it off unsubscribes and prunes the row.
|
||||
const { user } = useUserSession()
|
||||
const config = useRuntimeConfig()
|
||||
const ownerEmail = config.public.ownerEmail
|
||||
const vapidPublicKey = config.public.vapidPublicKey
|
||||
|
||||
const isOwner = computed(
|
||||
() =>
|
||||
!!user.value?.email &&
|
||||
!!ownerEmail &&
|
||||
user.value.email.toLowerCase() === ownerEmail.toLowerCase(),
|
||||
)
|
||||
|
||||
type State = 'loading' | 'idle' | 'subscribed' | 'denied' | 'unsupported' | 'misconfigured'
|
||||
const state = ref<State>('loading')
|
||||
const busy = ref(false)
|
||||
|
||||
const supported = () =>
|
||||
import.meta.client &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isOwner.value) return
|
||||
if (!supported()) return void (state.value = 'unsupported')
|
||||
if (!vapidPublicKey) return void (state.value = 'misconfigured')
|
||||
if (Notification.permission === 'denied') return void (state.value = 'denied')
|
||||
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const existing = await reg?.pushManager.getSubscription()
|
||||
state.value = existing ? 'subscribed' : 'idle'
|
||||
})
|
||||
|
||||
async function enable() {
|
||||
busy.value = true
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
state.value = permission === 'denied' ? 'denied' : 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
await $fetch('/api/subscriptions', { method: 'POST', body: sub.toJSON() })
|
||||
state.value = 'subscribed'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
busy.value = true
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const sub = await reg?.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await $fetch('/api/subscriptions', { method: 'DELETE', body: { endpoint: sub.endpoint } })
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
state.value = 'idle'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// VAPID public keys are URL-safe base64; PushManager wants the raw bytes,
|
||||
// backed by a concrete ArrayBuffer to satisfy the BufferSource type.
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const normalised = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(normalised)
|
||||
const bytes = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isOwner" class="text-sm">
|
||||
<div v-if="state === 'subscribed'" class="flex items-center gap-2">
|
||||
<span class="opacity-70">🔔 Notifications on</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" :disabled="busy" @click="disable">
|
||||
Turn off
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="state === 'idle'"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm"
|
||||
:disabled="busy"
|
||||
@click="enable"
|
||||
>
|
||||
Enable notifications
|
||||
</button>
|
||||
<p v-else-if="state === 'denied'" class="opacity-60">
|
||||
Notifications are blocked in your browser settings.
|
||||
</p>
|
||||
<p v-else-if="state === 'unsupported'" class="opacity-60">
|
||||
This browser can’t receive push notifications.
|
||||
</p>
|
||||
<p v-else-if="state === 'misconfigured'" class="opacity-60">
|
||||
Push notifications aren’t configured.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// Single domain (ADR 0004): report at `/`, view all defects at `/defects`,
|
||||
// with a persistent header to move between them.
|
||||
const { loggedIn, user, clear } = useUserSession()
|
||||
|
||||
async function signOut() {
|
||||
await clear()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,6 +31,12 @@
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="loggedIn" class="flex items-center gap-3 pl-2">
|
||||
<span class="hidden text-sm opacity-70 sm:inline">{{ user?.email }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="signOut">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
13
app/middleware/auth.global.ts
Normal file
13
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Redirect unauthenticated visitors to the login page, and bounce already
|
||||
// logged-in users away from it (F9). Runs on both SSR and client navigations;
|
||||
// the server data API is gated independently in server/middleware/auth.ts.
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { loggedIn } = useUserSession()
|
||||
|
||||
if (!loggedIn.value && to.path !== '/login') {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
if (loggedIn.value && to.path === '/login') {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -29,9 +29,10 @@ const openSection = ref<string | null>(null)
|
||||
|
||||
<template>
|
||||
<main class="mx-auto flex max-w-5xl flex-col items-center gap-6 p-6">
|
||||
<header class="text-center">
|
||||
<header class="flex flex-col items-center gap-2 text-center">
|
||||
<h1>Andon — weak points</h1>
|
||||
<p class="opacity-70">Every defect filed on the board.</p>
|
||||
<EnableNotifications />
|
||||
</header>
|
||||
|
||||
<DotMap :defects="defects" :counts="counts" @select="openSection = $event" />
|
||||
|
||||
32
app/pages/login.vue
Normal file
32
app/pages/login.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// Login gate (F9). Standalone (no app header — its nav points at gated routes).
|
||||
// The button is a real anchor so it hits the server OAuth route directly.
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const error = computed(() => {
|
||||
if (route.query.error === 'domain') return 'Sign in with your @theodo.com Google account.'
|
||||
if (route.query.error === 'oauth') return 'Sign-in failed — please try again.'
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-screen items-center justify-center p-6">
|
||||
<div class="card bg-base-100 border-base-300 w-full max-w-sm border shadow-sm">
|
||||
<div class="card-body items-center text-center gap-4">
|
||||
<h1 class="flex items-center gap-2 text-2xl font-semibold">
|
||||
<span class="size-2.5 rounded-full bg-[#e11d48]" aria-hidden="true" />
|
||||
Project Board Andon
|
||||
</h1>
|
||||
<p class="opacity-70">Sign in to report and view board defects.</p>
|
||||
|
||||
<p v-if="error" role="alert" class="alert alert-error text-sm">{{ error }}</p>
|
||||
|
||||
<a href="/auth/google/callback" class="btn btn-primary w-full">
|
||||
Sign in with Google
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -13,10 +13,10 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgres://andon:andon@db:5432/andon}
|
||||
NUXT_SESSION_SECRET: ${NUXT_SESSION_SECRET:-change-me}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-}
|
||||
NUXT_SESSION_PASSWORD: ${NUXT_SESSION_PASSWORD:-}
|
||||
NUXT_OAUTH_GOOGLE_CLIENT_ID: ${NUXT_OAUTH_GOOGLE_CLIENT_ID:-}
|
||||
NUXT_OAUTH_GOOGLE_CLIENT_SECRET: ${NUXT_OAUTH_GOOGLE_CLIENT_SECRET:-}
|
||||
NUXT_OAUTH_GOOGLE_REDIRECT_URL: ${NUXT_OAUTH_GOOGLE_REDIRECT_URL:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:owner@theodo.com}
|
||||
|
||||
@@ -4,8 +4,20 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/eslint', '@nuxt/test-utils/module'],
|
||||
modules: ['@nuxt/eslint', '@nuxt/test-utils/module', 'nuxt-auth-utils'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
runtimeConfig: {
|
||||
// VAPID private key + subject for signing Web Push (server-only, T11).
|
||||
vapidPrivateKey: '', // NUXT_VAPID_PRIVATE_KEY
|
||||
vapidSubject: '', // NUXT_VAPID_SUBJECT
|
||||
public: {
|
||||
// VAPID public key the browser subscribes with (T10).
|
||||
vapidPublicKey: '', // NUXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
// The single owner allowed to enrol for push (G3). Not secret; gates both
|
||||
// the server enrol endpoint and the client-side "Enable" button.
|
||||
ownerEmail: '', // NUXT_PUBLIC_OWNER_EMAIL
|
||||
},
|
||||
},
|
||||
// Tailwind v4 integrates as a Vite plugin (no @nuxtjs/tailwindcss module).
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"db:migrate": "tsx server/db/migrate.ts",
|
||||
"db:seed": "tsx server/db/seed.ts",
|
||||
"db:verify": "tsx scripts/verify-db.ts",
|
||||
"vapid:keys": "tsx scripts/generate-vapid.ts",
|
||||
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
||||
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
|
||||
"docker:dev:down": "docker compose -f docker-compose.dev.yml down"
|
||||
@@ -25,15 +26,18 @@
|
||||
"arktype": "^2.2.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"nuxt": "^4.4.6",
|
||||
"nuxt-auth-utils": "^0.5.29",
|
||||
"pg": "^8.21.0",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7"
|
||||
"vue-router": "^5.0.7",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/test-utils": "^4.0.3",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"daisyui": "^5.5.20",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
||||
235
pnpm-lock.yaml
generated
235
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
nuxt:
|
||||
specifier: ^4.4.6
|
||||
version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0)
|
||||
nuxt-auth-utils:
|
||||
specifier: ^0.5.29
|
||||
version: 0.5.29(magicast@0.5.3)
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.7(@vue/compiler-sfc@3.5.34)(vue@3.5.34(typescript@6.0.3))
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
devDependencies:
|
||||
'@nuxt/eslint':
|
||||
specifier: ^1.15.2
|
||||
@@ -39,6 +45,9 @@ importers:
|
||||
'@types/pg':
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.10
|
||||
version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
|
||||
@@ -69,6 +78,18 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@adonisjs/hash@9.1.1':
|
||||
resolution: {integrity: sha512-ZkRguwjAp4skKvKDdRAfdJ2oqQ0N7p9l3sioyXO1E8o0WcsyDgEpsTQtuVNoIdMiw4sn4gJlmL3nyF4BcK1ZDQ==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
peerDependencies:
|
||||
argon2: ^0.31.2 || ^0.41.0 || ^0.43.0
|
||||
bcrypt: ^5.1.1 || ^6.0.0
|
||||
peerDependenciesMeta:
|
||||
argon2:
|
||||
optional: true
|
||||
bcrypt:
|
||||
optional: true
|
||||
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
@@ -1667,6 +1688,10 @@ packages:
|
||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@phc/format@1.0.0':
|
||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1683,6 +1708,17 @@ packages:
|
||||
'@poppinss/exception@1.2.3':
|
||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||
|
||||
'@poppinss/object-builder@1.1.0':
|
||||
resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
|
||||
'@poppinss/string@1.7.2':
|
||||
resolution: {integrity: sha512-A182GLDfi36iDCbhDrHB0xzrPM1fO3GHnhCDIdadf8C6eycgct4m7zusbLwEh6GPaj2Pz5BVos7XK16w7tZ7wQ==}
|
||||
|
||||
'@poppinss/utils@6.10.1':
|
||||
resolution: {integrity: sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA==}
|
||||
engines: {node: '>=18.16.0'}
|
||||
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
@@ -2050,9 +2086,15 @@ packages:
|
||||
'@types/pg@8.20.0':
|
||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||
|
||||
'@types/pluralize@0.0.33':
|
||||
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
|
||||
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
@@ -2448,6 +2490,9 @@ packages:
|
||||
arktype@2.2.0:
|
||||
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2546,6 +2591,9 @@ packages:
|
||||
birpc@4.0.0:
|
||||
resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
|
||||
|
||||
bn.js@4.12.3:
|
||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -2569,6 +2617,9 @@ packages:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -2611,6 +2662,10 @@ packages:
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
case-anything@3.1.2:
|
||||
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2994,6 +3049,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
editorconfig@1.0.7:
|
||||
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3332,6 +3390,10 @@ packages:
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
flattie@1.1.1:
|
||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3462,6 +3524,10 @@ packages:
|
||||
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
|
||||
http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3596,6 +3662,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3641,6 +3710,12 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -3830,6 +3905,9 @@ packages:
|
||||
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -3842,6 +3920,9 @@ packages:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -3947,6 +4028,23 @@ packages:
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
nuxt-auth-utils@0.5.29:
|
||||
resolution: {integrity: sha512-aQ9oD8QR51jUCe2BFEsO/G/E0K+XUy8Skjn3hYcNLiqZ9XE4Y3uT/ozBCmAQ+TR3WW6prj8vUR0wQes8W1N0PA==}
|
||||
peerDependencies:
|
||||
'@atproto/api': ^0.13.15
|
||||
'@atproto/oauth-client-node': ^0.2.0
|
||||
'@simplewebauthn/browser': ^11.0.0
|
||||
'@simplewebauthn/server': ^11.0.0
|
||||
peerDependenciesMeta:
|
||||
'@atproto/api':
|
||||
optional: true
|
||||
'@atproto/oauth-client-node':
|
||||
optional: true
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
'@simplewebauthn/server':
|
||||
optional: true
|
||||
|
||||
nuxt@4.4.6:
|
||||
resolution: {integrity: sha512-QAApJpAx3yGf3pYudALkInuBfv0WkHCiol6ntTvh/lwKwYrcU/MRI1nLNGt0QNyUCgBWdOQukd3z67VJ2xGd0Q==}
|
||||
engines: {node: ^22.12.0 || ^24.11.0 || >=26.0.0}
|
||||
@@ -3965,6 +4063,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
oauth4webapi@3.8.6:
|
||||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||
|
||||
object-deep-merge@2.0.1:
|
||||
resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==}
|
||||
|
||||
@@ -4000,6 +4101,9 @@ packages:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
openid-client@6.8.4:
|
||||
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4480,6 +4584,13 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sax@1.6.0:
|
||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -4491,6 +4602,9 @@ packages:
|
||||
scule@1.3.0:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -4558,6 +4672,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
slugify@1.6.9:
|
||||
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
smob@1.6.2:
|
||||
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -5118,6 +5236,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -5234,6 +5357,11 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adonisjs/hash@9.1.1':
|
||||
dependencies:
|
||||
'@phc/format': 1.0.0
|
||||
'@poppinss/utils': 6.10.1
|
||||
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
dependencies:
|
||||
package-manager-detector: 1.6.0
|
||||
@@ -6654,6 +6782,8 @@ snapshots:
|
||||
'@parcel/watcher-win32-ia32': 2.5.6
|
||||
'@parcel/watcher-win32-x64': 2.5.6
|
||||
|
||||
'@phc/format@1.0.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -6671,6 +6801,24 @@ snapshots:
|
||||
|
||||
'@poppinss/exception@1.2.3': {}
|
||||
|
||||
'@poppinss/object-builder@1.1.0': {}
|
||||
|
||||
'@poppinss/string@1.7.2':
|
||||
dependencies:
|
||||
'@types/pluralize': 0.0.33
|
||||
case-anything: 3.1.2
|
||||
pluralize: 8.0.0
|
||||
slugify: 1.6.9
|
||||
|
||||
'@poppinss/utils@6.10.1':
|
||||
dependencies:
|
||||
'@poppinss/exception': 1.2.3
|
||||
'@poppinss/object-builder': 1.1.0
|
||||
'@poppinss/string': 1.7.2
|
||||
flattie: 1.1.1
|
||||
safe-stable-stringify: 2.5.0
|
||||
secure-json-parse: 4.1.0
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@rollup/plugin-alias@6.0.0(rollup@4.60.4)':
|
||||
@@ -6937,8 +7085,14 @@ snapshots:
|
||||
pg-protocol: 1.14.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/pluralize@0.0.33': {}
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 25.9.1
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
@@ -7393,6 +7547,13 @@ snapshots:
|
||||
'@ark/util': 0.56.0
|
||||
arkregex: 0.0.5
|
||||
|
||||
asn1.js@5.4.1:
|
||||
dependencies:
|
||||
bn.js: 4.12.3
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-kit@2.2.0:
|
||||
@@ -7468,6 +7629,8 @@ snapshots:
|
||||
|
||||
birpc@4.0.0: {}
|
||||
|
||||
bn.js@4.12.3: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
@@ -7492,6 +7655,8 @@ snapshots:
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
@@ -7540,6 +7705,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
case-anything@3.1.2: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
change-case@5.4.4: {}
|
||||
@@ -7798,6 +7965,10 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
editorconfig@1.0.7:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
@@ -8260,6 +8431,8 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -8391,6 +8564,8 @@ snapshots:
|
||||
|
||||
http-shutdown@1.2.2: {}
|
||||
|
||||
http_ece@1.2.0: {}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -8503,6 +8678,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-beautify@1.15.4:
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
@@ -8538,6 +8715,17 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -8710,6 +8898,8 @@ snapshots:
|
||||
|
||||
mimic-fn@4.0.0: {}
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
@@ -8722,6 +8912,8 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minizlib@3.1.0:
|
||||
@@ -8895,6 +9087,24 @@ snapshots:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
nuxt-auth-utils@0.5.29(magicast@0.5.3):
|
||||
dependencies:
|
||||
'@adonisjs/hash': 9.1.1
|
||||
'@nuxt/kit': 4.4.6(magicast@0.5.3)
|
||||
defu: 6.1.7
|
||||
h3: 1.15.11
|
||||
hookable: 6.1.1
|
||||
jose: 6.2.3
|
||||
ofetch: 1.5.1
|
||||
openid-client: 6.8.4
|
||||
pathe: 2.0.3
|
||||
scule: 1.3.0
|
||||
uncrypto: 0.1.3
|
||||
transitivePeerDependencies:
|
||||
- argon2
|
||||
- bcrypt
|
||||
- magicast
|
||||
|
||||
nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0):
|
||||
dependencies:
|
||||
'@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@6.0.3)
|
||||
@@ -9031,6 +9241,8 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
tinyexec: 1.2.2
|
||||
|
||||
oauth4webapi@3.8.6: {}
|
||||
|
||||
object-deep-merge@2.0.1: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
@@ -9071,6 +9283,11 @@ snapshots:
|
||||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
openid-client@6.8.4:
|
||||
dependencies:
|
||||
jose: 6.2.3
|
||||
oauth4webapi: 3.8.6
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -9587,6 +9804,10 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sax@1.6.0: {}
|
||||
|
||||
scslre@0.3.0:
|
||||
@@ -9597,6 +9818,8 @@ snapshots:
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.1: {}
|
||||
@@ -9670,6 +9893,8 @@ snapshots:
|
||||
|
||||
slash@5.1.0: {}
|
||||
|
||||
slugify@1.6.9: {}
|
||||
|
||||
smob@1.6.2: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -10236,6 +10461,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
web-push@3.6.7:
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6
|
||||
jws: 4.0.1
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
33
public/sw.js
Normal file
33
public/sw.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Web Push service worker (T10). Registered from EnableNotifications.vue and
|
||||
// served at the site root so it controls the whole origin. Its job is to
|
||||
// receive pushes the server sends (T11) and surface them as OS notifications.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = {}
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {}
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
const title = payload.title || 'New defect filed'
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: payload.body || '',
|
||||
tag: payload.tag,
|
||||
data: { url: payload.url || '/defects' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const target = (event.notification.data && event.notification.data.url) || '/defects'
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url.includes(target) && 'focus' in client) return client.focus()
|
||||
}
|
||||
return self.clients.openWindow(target)
|
||||
}),
|
||||
)
|
||||
})
|
||||
10
scripts/generate-vapid.ts
Normal file
10
scripts/generate-vapid.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// One-off helper to mint a VAPID key pair for Web Push (T10).
|
||||
// Run with `pnpm vapid:keys`, then paste the values into your .env:
|
||||
// NUXT_PUBLIC_VAPID_PUBLIC_KEY (sent to the browser)
|
||||
// NUXT_VAPID_PRIVATE_KEY (server secret — never expose)
|
||||
import webpush from 'web-push'
|
||||
|
||||
const { publicKey, privateKey } = webpush.generateVAPIDKeys()
|
||||
|
||||
console.log('NUXT_PUBLIC_VAPID_PUBLIC_KEY=' + publicKey)
|
||||
console.log('NUXT_VAPID_PRIVATE_KEY=' + privateKey)
|
||||
@@ -3,6 +3,7 @@ import { getDb } from '../db/client'
|
||||
import { createDefect } from '../db/repositories/defects'
|
||||
import { DefectInput } from '../utils/defectInput'
|
||||
import { getReporter } from '../utils/getReporter'
|
||||
import { sendPushOnDefectFiled } from '../utils/sendPush'
|
||||
|
||||
// File a defect against a board section (F2). Reporter comes from the seam,
|
||||
// not the client; the timestamp defaults in the DB.
|
||||
@@ -11,5 +12,24 @@ export default defineEventHandler(async (event) => {
|
||||
if (input instanceof type.errors) {
|
||||
throw createError({ statusCode: 400, statusMessage: input.summary })
|
||||
}
|
||||
return createDefect(getDb(), { ...input, reporterEmail: getReporter(event) })
|
||||
|
||||
const db = getDb()
|
||||
const defect = await createDefect(db, { ...input, reporterEmail: await getReporter(event) })
|
||||
|
||||
// Notify subscribed devices (F7) — fire-and-forget so filing stays fast.
|
||||
// `waitUntil` keeps the runtime alive for the send without delaying the response.
|
||||
const config = useRuntimeConfig(event)
|
||||
event.waitUntil(
|
||||
sendPushOnDefectFiled(
|
||||
db,
|
||||
{
|
||||
subject: config.vapidSubject,
|
||||
publicKey: config.public.vapidPublicKey,
|
||||
privateKey: config.vapidPrivateKey,
|
||||
},
|
||||
{ sectionId: defect.sectionId, projectId: defect.projectId, verbatim: defect.verbatim },
|
||||
),
|
||||
)
|
||||
|
||||
return defect
|
||||
})
|
||||
|
||||
23
server/api/subscriptions.delete.ts
Normal file
23
server/api/subscriptions.delete.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getDb } from '../db/client'
|
||||
import { deleteSubscriptionByEndpoint } from '../db/repositories/pushSubscriptions'
|
||||
import { getReporter } from '../utils/getReporter'
|
||||
import { isOwner } from '../utils/isOwner'
|
||||
|
||||
// Unenrol a device — when the owner turns notifications off, the client sends
|
||||
// the endpoint it is dropping. Owner-only, like enrolment.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const email = await getReporter(event)
|
||||
const ownerEmail = useRuntimeConfig(event).public.ownerEmail
|
||||
if (!isOwner(email, ownerEmail)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Notifications are restricted to the owner' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ endpoint?: unknown }>(event)
|
||||
const endpoint = typeof body?.endpoint === 'string' ? body.endpoint : ''
|
||||
if (!endpoint) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'endpoint is required' })
|
||||
}
|
||||
|
||||
const removed = await deleteSubscriptionByEndpoint(getDb(), endpoint)
|
||||
return { removed }
|
||||
})
|
||||
29
server/api/subscriptions.post.ts
Normal file
29
server/api/subscriptions.post.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type } from 'arktype'
|
||||
import { getDb } from '../db/client'
|
||||
import { saveSubscription } from '../db/repositories/pushSubscriptions'
|
||||
import { PushSubscriptionInput } from '../utils/pushSubscriptionInput'
|
||||
import { getReporter } from '../utils/getReporter'
|
||||
import { isOwner } from '../utils/isOwner'
|
||||
|
||||
// Enrol a device for Web Push (F8). Owner-only (G3): the session email must
|
||||
// match the configured owner. The auth middleware already requires a session.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const email = await getReporter(event)
|
||||
const ownerEmail = useRuntimeConfig(event).public.ownerEmail
|
||||
if (!isOwner(email, ownerEmail)) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Notifications are restricted to the owner' })
|
||||
}
|
||||
|
||||
const input = PushSubscriptionInput(await readBody(event))
|
||||
if (input instanceof type.errors) {
|
||||
throw createError({ statusCode: 400, statusMessage: input.summary })
|
||||
}
|
||||
|
||||
await saveSubscription(getDb(), {
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.keys.p256dh,
|
||||
auth: input.keys.auth,
|
||||
reporterEmail: email,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -15,7 +15,7 @@ export interface NewDefect {
|
||||
/** Persist a filed defect and return the stored row. */
|
||||
export async function createDefect(db: Db, input: NewDefect) {
|
||||
const [row] = await db.insert(defects).values(input).returning()
|
||||
return row
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Recent defects, newest first — the dashboard feed (F5/F6). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, sql } from 'drizzle-orm'
|
||||
import { asc, eq, sql } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import type * as schema from '../schema'
|
||||
import { projects } from '../schema'
|
||||
@@ -34,6 +34,16 @@ export async function createProject(db: Db, rawName: string): Promise<Project> {
|
||||
}
|
||||
}
|
||||
|
||||
/** A project's name by id — used to label the push notification on file (T11). */
|
||||
export async function getProjectName(db: Db, id: string): Promise<string | undefined> {
|
||||
const [row] = await db
|
||||
.select({ name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.limit(1)
|
||||
return row?.name
|
||||
}
|
||||
|
||||
async function findByName(db: Db, name: string): Promise<Project | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
|
||||
48
server/db/repositories/pushSubscriptions.ts
Normal file
48
server/db/repositories/pushSubscriptions.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import type * as schema from '../schema'
|
||||
import { pushSubscriptions } from '../schema'
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>
|
||||
|
||||
export interface NewPushSubscription {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
reporterEmail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (or refresh) a device's push subscription (F8). Keyed by `endpoint`:
|
||||
* re-subscribing the same device rotates its keys in place, while a different
|
||||
* device is kept as a separate row so the owner can enrol several phones.
|
||||
*/
|
||||
export async function saveSubscription(db: Db, sub: NewPushSubscription) {
|
||||
const [row] = await db
|
||||
.insert(pushSubscriptions)
|
||||
.values(sub)
|
||||
.onConflictDoUpdate({
|
||||
target: pushSubscriptions.endpoint,
|
||||
set: { p256dh: sub.p256dh, auth: sub.auth, reporterEmail: sub.reporterEmail },
|
||||
})
|
||||
.returning()
|
||||
return row!
|
||||
}
|
||||
|
||||
/** All stored subscriptions — the fan-out target when a defect is filed (T11). */
|
||||
export async function listSubscriptions(db: Db) {
|
||||
return db.select().from(pushSubscriptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription by endpoint and report how many rows went. Called when
|
||||
* the push service rejects an endpoint with HTTP 410 (Gone), pruning the stale
|
||||
* device so we stop sending to it.
|
||||
*/
|
||||
export async function deleteSubscriptionByEndpoint(db: Db, endpoint: string): Promise<number> {
|
||||
const deleted = await db
|
||||
.delete(pushSubscriptions)
|
||||
.where(eq(pushSubscriptions.endpoint, endpoint))
|
||||
.returning({ id: pushSubscriptions.id })
|
||||
return deleted.length
|
||||
}
|
||||
15
server/middleware/auth.ts
Normal file
15
server/middleware/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isPublicApiPath } from '../utils/authPaths'
|
||||
|
||||
// Default-deny gate for the data API (F9). Every `/api` route requires an
|
||||
// authenticated session except the explicitly public ones (health probe, the
|
||||
// session endpoint). Pages are guarded separately by app/middleware/auth.global
|
||||
// so static assets and the OAuth route stay reachable while unauthenticated.
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (!event.path.startsWith('/api/')) return
|
||||
if (isPublicApiPath(event.path)) return
|
||||
|
||||
const { user } = await getUserSession(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
}
|
||||
})
|
||||
25
server/routes/auth/google/callback.get.ts
Normal file
25
server/routes/auth/google/callback.get.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { isAllowedGoogleUser, type GoogleUserInfo } from '../../../utils/allowedGoogleUser'
|
||||
|
||||
// Google OAuth entry + callback (F9). nuxt-auth-utils uses one handler for
|
||||
// both legs: with no `?code` it redirects to Google; on return it exchanges the
|
||||
// code and hands us the verified userinfo. We then re-check the email domain
|
||||
// server-side (the `hd` claim is not trusted) before minting a session.
|
||||
export default defineOAuthGoogleEventHandler({
|
||||
config: {},
|
||||
async onSuccess(event, { user }) {
|
||||
const info = user as GoogleUserInfo
|
||||
if (!isAllowedGoogleUser(info)) {
|
||||
await clearUserSession(event)
|
||||
return sendRedirect(event, '/login?error=domain')
|
||||
}
|
||||
await setUserSession(event, {
|
||||
// isAllowedGoogleUser guarantees a present email; narrow for the compiler.
|
||||
user: { email: info.email!, name: user.name, picture: user.picture },
|
||||
})
|
||||
return sendRedirect(event, '/')
|
||||
},
|
||||
onError(event, error) {
|
||||
console.error('Google OAuth error:', error)
|
||||
return sendRedirect(event, '/login?error=oauth')
|
||||
},
|
||||
})
|
||||
21
server/utils/allowedGoogleUser.ts
Normal file
21
server/utils/allowedGoogleUser.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Authorisation gate for Google identities (F9, DESIGN T9).
|
||||
//
|
||||
// Access is restricted to the company's verified email domain. We deliberately
|
||||
// ignore Google's `hd` (hosted-domain) claim as the source of truth: a personal
|
||||
// account can be made to present an `hd`, so we re-derive the domain from the
|
||||
// email Google itself reports as verified.
|
||||
const ALLOWED_EMAIL_DOMAIN = 'theodo.com'
|
||||
|
||||
export interface GoogleUserInfo {
|
||||
email?: string
|
||||
email_verified?: boolean | string
|
||||
// `hd` is intentionally not consulted — see the module comment.
|
||||
hd?: string
|
||||
}
|
||||
|
||||
export function isAllowedGoogleUser(user: GoogleUserInfo): boolean {
|
||||
const verified = user.email_verified === true || user.email_verified === 'true'
|
||||
if (!verified || !user.email) return false
|
||||
const domain = user.email.split('@')[1]?.toLowerCase()
|
||||
return domain === ALLOWED_EMAIL_DOMAIN
|
||||
}
|
||||
12
server/utils/authPaths.ts
Normal file
12
server/utils/authPaths.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Which `/api` routes bypass the default-deny auth middleware (F9).
|
||||
//
|
||||
// `/api/health` must answer for the orchestrator's liveness probe before any
|
||||
// session exists; `/api/_auth/*` is nuxt-auth-utils' own session endpoint that
|
||||
// the client uses to read/clear the session. Everything else under `/api`
|
||||
// requires an authenticated session.
|
||||
const PUBLIC_API_PREFIXES = ['/api/health', '/api/_auth/']
|
||||
|
||||
export function isPublicApiPath(path: string): boolean {
|
||||
const pathname = path.split('?')[0]!
|
||||
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))
|
||||
}
|
||||
@@ -2,9 +2,14 @@ import type { H3Event } from 'h3'
|
||||
|
||||
/**
|
||||
* Resolves the reporter's email — the one seam between filing and auth (ADR
|
||||
* 0002). For now it returns a fixed dev identity so the filing slice works
|
||||
* before OAuth exists; Task 9 swaps this to read the verified session email.
|
||||
* 0002). Backed by the authenticated Google session (Task 9) so defects record
|
||||
* the true reporter. The auth middleware already gates the filing endpoint, so
|
||||
* a missing session here is an unexpected 401, not a normal path.
|
||||
*/
|
||||
export function getReporter(_event: H3Event): string {
|
||||
return process.env.DEV_REPORTER_EMAIL ?? 'dev@theodo.com'
|
||||
export async function getReporter(event: H3Event): Promise<string> {
|
||||
const { user } = await getUserSession(event)
|
||||
if (!user?.email) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
}
|
||||
return user.email
|
||||
}
|
||||
|
||||
7
server/utils/isOwner.ts
Normal file
7
server/utils/isOwner.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Owner-only gate for Web Push (T10). v1 notifies a single configured owner
|
||||
// (DESIGN G3); the owner email comes from runtime config server-side. If no
|
||||
// owner is configured, nobody qualifies — push enrolment stays closed.
|
||||
export function isOwner(email: string | undefined, ownerEmail: string | undefined): boolean {
|
||||
if (!email || !ownerEmail) return false
|
||||
return email.toLowerCase() === ownerEmail.toLowerCase()
|
||||
}
|
||||
16
server/utils/pushSubscriptionInput.ts
Normal file
16
server/utils/pushSubscriptionInput.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type } from 'arktype'
|
||||
|
||||
// Validated push-subscription body — the browser's PushSubscription.toJSON()
|
||||
// shape (T10/F8). Never trusted from the client beyond these constraints; the
|
||||
// reporter is resolved server-side from the session, not sent here.
|
||||
const nonEmptyKey = type('string').narrow((s, ctx) => s.length > 0 || ctx.reject('a non-empty key'))
|
||||
|
||||
export const PushSubscriptionInput = type({
|
||||
endpoint: 'string.url',
|
||||
keys: {
|
||||
p256dh: nonEmptyKey,
|
||||
auth: nonEmptyKey,
|
||||
},
|
||||
})
|
||||
|
||||
export type PushSubscriptionInputData = typeof PushSubscriptionInput.infer
|
||||
87
server/utils/sendPush.ts
Normal file
87
server/utils/sendPush.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import webpush from 'web-push'
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||
import type * as schema from '../db/schema'
|
||||
import {
|
||||
listSubscriptions,
|
||||
deleteSubscriptionByEndpoint,
|
||||
} from '../db/repositories/pushSubscriptions'
|
||||
import { getProjectName } from '../db/repositories/projects'
|
||||
import { sections } from '../../app/board/definition'
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>
|
||||
|
||||
const sectionLabels = new Map(sections.map((s) => [s.id, s.label]))
|
||||
|
||||
const SNIPPET_LIMIT = 140
|
||||
|
||||
export interface VapidConfig {
|
||||
subject: string
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export interface FiledDefect {
|
||||
sectionId: string
|
||||
projectId: string
|
||||
verbatim: string
|
||||
}
|
||||
|
||||
/** The notification body the service worker (T10) expects: `{ title, body, tag, url }`. */
|
||||
export function buildPayload(input: { sectionLabel: string; projectName: string; verbatim: string }) {
|
||||
const snippet =
|
||||
input.verbatim.length > SNIPPET_LIMIT
|
||||
? input.verbatim.slice(0, SNIPPET_LIMIT - 1).trimEnd() + '…'
|
||||
: input.verbatim
|
||||
return {
|
||||
title: `New defect — ${input.sectionLabel}`,
|
||||
body: `${input.projectName}: ${snippet}`,
|
||||
tag: 'defect-filed',
|
||||
url: '/defects',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a Web Push out to every enrolled device after a defect is filed (F7).
|
||||
* Called fire-and-forget from the file-defect handler (via `event.waitUntil`)
|
||||
* so filing latency is unaffected. Never throws: a stale endpoint (404/410) is
|
||||
* pruned, any other failure is logged and the remaining devices still get sent.
|
||||
*/
|
||||
export async function sendPushOnDefectFiled(
|
||||
db: Db,
|
||||
vapid: VapidConfig,
|
||||
defect: FiledDefect,
|
||||
): Promise<void> {
|
||||
// Without keys there is nothing to sign with (dev / preview); stay silent.
|
||||
if (!vapid.publicKey || !vapid.privateKey) return
|
||||
|
||||
const subscriptions = await listSubscriptions(db)
|
||||
if (subscriptions.length === 0) return
|
||||
|
||||
const projectName = (await getProjectName(db, defect.projectId)) ?? 'Unknown project'
|
||||
const sectionLabel = sectionLabels.get(defect.sectionId) ?? defect.sectionId
|
||||
const payload = JSON.stringify(buildPayload({ sectionLabel, projectName, verbatim: defect.verbatim }))
|
||||
|
||||
webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey)
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map((sub) =>
|
||||
webpush
|
||||
.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
)
|
||||
.catch(async (err: unknown) => {
|
||||
if (
|
||||
err instanceof webpush.WebPushError &&
|
||||
(err.statusCode === 404 || err.statusCode === 410)
|
||||
) {
|
||||
// The device is gone — drop it so we stop sending (the 410 path).
|
||||
await deleteSubscriptionByEndpoint(db, sub.endpoint)
|
||||
} else {
|
||||
// Transient or unexpected: keep the device, log, and move on (no retry queue in v1).
|
||||
console.error('[push] send failed for', sub.endpoint, err)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
12
shared/auth.d.ts
vendored
Normal file
12
shared/auth.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Shape of the authenticated session for nuxt-auth-utils (Task 9, F9).
|
||||
// Lives in shared/ so both the app and server TS projects pick up the
|
||||
// #auth-utils augmentation.
|
||||
declare module '#auth-utils' {
|
||||
interface User {
|
||||
email: string
|
||||
name?: string
|
||||
picture?: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -19,15 +19,15 @@ review.
|
||||
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
|
||||
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
||||
- [x] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
||||
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
||||
- [x] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
||||
|
||||
## Phase 4 — Authentication (G4)
|
||||
- [ ] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
|
||||
- [x] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
|
||||
- [ ] ⛳ **Checkpoint:** both views gated to @theodo.com, reporters real, human review
|
||||
|
||||
## Phase 5 — Notifications (G3)
|
||||
- [ ] **T10** Web Push subscribe + service worker (C10) — F8 (M) — _deps: T3, T9_
|
||||
- [ ] **T11** Send push on defect filed (fire-and-forget) — F7 (S) — _deps: T5, T10_
|
||||
- [x] **T10** Web Push subscribe + service worker (C10) — F8 (M) — _deps: T3, T9_
|
||||
- [x] **T11** Send push on defect filed (fire-and-forget) — F7 (S) — _deps: T5, T10_
|
||||
- [ ] ⛳ **Checkpoint:** file → push arrives ≤10s, filing latency unaffected, human review
|
||||
|
||||
## Phase 6 — Ship (F10)
|
||||
@@ -35,7 +35,7 @@ review.
|
||||
- [ ] ⛳ **Checkpoint:** data survives redeploy, both subdomains live, ready for review
|
||||
|
||||
## Blocked on open questions (see plan.md)
|
||||
- [ ] Google OAuth client provisioning (blocks T9)
|
||||
- [x] Google OAuth client provisioning (blocks T9) — client provided; redirect URI `…/auth/google/callback`
|
||||
- [ ] Confirm pnpm + Vitest + Playwright vs Theodo template (affects T1)
|
||||
- [ ] Coolify two-domain routing model (affects T12)
|
||||
- [ ] "Owner only" vs any subscribed user for push (affects T10/T11)
|
||||
- [x] "Owner only" vs any subscribed user for push (affects T10/T11) — owner-only, gated by `NUXT_PUBLIC_OWNER_EMAIL`
|
||||
|
||||
52
tests/auth.spec.ts
Normal file
52
tests/auth.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isAllowedGoogleUser } from '../server/utils/allowedGoogleUser'
|
||||
import { isPublicApiPath } from '../server/utils/authPaths'
|
||||
|
||||
// F9: access is restricted to verified @theodo.com identities. The Google
|
||||
// `hd` (hosted-domain) claim is NOT trusted on its own — we re-derive the
|
||||
// domain from the verified email server-side (DESIGN T9).
|
||||
describe('isAllowedGoogleUser', () => {
|
||||
it('allows a verified @theodo.com email', () => {
|
||||
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies a non-theodo verified email even when hd claims theodo.com', () => {
|
||||
expect(
|
||||
isAllowedGoogleUser({ email: 'attacker@gmail.com', email_verified: true, hd: 'theodo.com' }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('denies a theodo email that Google has not verified', () => {
|
||||
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('matches the domain case-insensitively', () => {
|
||||
expect(isAllowedGoogleUser({ email: 'jane@Theodo.COM', email_verified: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies when the email is missing', () => {
|
||||
expect(isAllowedGoogleUser({ email_verified: true, hd: 'theodo.com' })).toBe(false)
|
||||
})
|
||||
|
||||
it('denies a look-alike domain that merely ends in theodo.com', () => {
|
||||
expect(isAllowedGoogleUser({ email: 'eve@nottheodo.com', email_verified: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPublicApiPath', () => {
|
||||
it('treats the health check as public', () => {
|
||||
expect(isPublicApiPath('/api/health')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats the session endpoint as public', () => {
|
||||
expect(isPublicApiPath('/api/_auth/session')).toBe(true)
|
||||
})
|
||||
|
||||
it('gates the defects API', () => {
|
||||
expect(isPublicApiPath('/api/defects')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores the query string when matching', () => {
|
||||
expect(isPublicApiPath('/api/health?probe=1')).toBe(true)
|
||||
})
|
||||
})
|
||||
192
tests/push-send.spec.ts
Normal file
192
tests/push-send.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
|
||||
import webpush from 'web-push'
|
||||
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
|
||||
import { createProject } from '../server/db/repositories/projects'
|
||||
import { saveSubscription, listSubscriptions } from '../server/db/repositories/pushSubscriptions'
|
||||
import { buildPayload, sendPushOnDefectFiled } from '../server/utils/sendPush'
|
||||
|
||||
// web-push is mocked so the send never leaves the process: we assert on the
|
||||
// payload it would deliver and simulate the push service's 410/404 responses.
|
||||
const { sendNotification, setVapidDetails } = vi.hoisted(() => ({
|
||||
sendNotification: vi.fn(),
|
||||
setVapidDetails: vi.fn(),
|
||||
}))
|
||||
vi.mock('web-push', () => {
|
||||
class WebPushError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
return { default: { sendNotification, setVapidDetails, WebPushError } }
|
||||
})
|
||||
|
||||
const vapid = { subject: 'mailto:owner@theodo.com', publicKey: 'pub-key', privateKey: 'priv-key' }
|
||||
|
||||
describe('buildPayload', () => {
|
||||
it('renders the section label, project and verbatim into the SW payload shape', () => {
|
||||
const payload = buildPayload({
|
||||
sectionLabel: 'Macroplan',
|
||||
projectName: 'Acme',
|
||||
verbatim: 'builds are flaky',
|
||||
})
|
||||
expect(payload).toMatchObject({
|
||||
title: expect.stringContaining('Macroplan'),
|
||||
body: expect.stringContaining('Acme'),
|
||||
url: '/defects',
|
||||
})
|
||||
expect(payload.body).toContain('builds are flaky')
|
||||
})
|
||||
|
||||
it('truncates a long verbatim to a snippet', () => {
|
||||
const payload = buildPayload({
|
||||
sectionLabel: 'Macroplan',
|
||||
projectName: 'Acme',
|
||||
verbatim: 'x'.repeat(300),
|
||||
})
|
||||
expect(payload.body.length).toBeLessThan(200)
|
||||
expect(payload.body).toContain('…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendPushOnDefectFiled', () => {
|
||||
let db: TestDb
|
||||
let pool: { end: () => Promise<void> }
|
||||
|
||||
beforeAll(async () => {
|
||||
const setup = await setupTestDb()
|
||||
db = setup.db
|
||||
pool = setup.pool
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll(db)
|
||||
sendNotification.mockReset().mockResolvedValue(undefined)
|
||||
setVapidDetails.mockReset()
|
||||
})
|
||||
|
||||
async function seedTwoDevices() {
|
||||
await saveSubscription(db, {
|
||||
endpoint: 'https://push.example/device-a',
|
||||
p256dh: 'p256dh-a',
|
||||
auth: 'auth-a',
|
||||
reporterEmail: 'owner@theodo.com',
|
||||
})
|
||||
await saveSubscription(db, {
|
||||
endpoint: 'https://push.example/device-b',
|
||||
p256dh: 'p256dh-b',
|
||||
auth: 'auth-b',
|
||||
reporterEmail: 'owner@theodo.com',
|
||||
})
|
||||
}
|
||||
|
||||
it('sets VAPID details and pushes the defect to every subscribed device', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await seedTwoDevices()
|
||||
|
||||
await sendPushOnDefectFiled(db, vapid, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'login is flaky',
|
||||
})
|
||||
|
||||
expect(setVapidDetails).toHaveBeenCalledWith(vapid.subject, vapid.publicKey, vapid.privateKey)
|
||||
expect(sendNotification).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [sub, payload] = sendNotification.mock.calls[0]!
|
||||
expect(sub).toMatchObject({ endpoint: expect.stringContaining('device-'), keys: expect.any(Object) })
|
||||
const decoded = JSON.parse(payload as string)
|
||||
expect(decoded.title).toContain('Macroplan')
|
||||
expect(decoded.body).toContain('Acme')
|
||||
expect(decoded.body).toContain('login is flaky')
|
||||
})
|
||||
|
||||
it('prunes a subscription when the push service returns 410 Gone', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await seedTwoDevices()
|
||||
sendNotification.mockImplementation((sub: webpush.PushSubscription) => {
|
||||
if (sub.endpoint.endsWith('device-a')) {
|
||||
return Promise.reject(new webpush.WebPushError('gone', 410))
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
await sendPushOnDefectFiled(db, vapid, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'x',
|
||||
})
|
||||
|
||||
const remaining = (await listSubscriptions(db)).map((s) => s.endpoint)
|
||||
expect(remaining).toEqual(['https://push.example/device-b'])
|
||||
})
|
||||
|
||||
it('prunes a subscription when the push service returns 404 Not Found', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await seedTwoDevices()
|
||||
sendNotification.mockImplementation((sub: webpush.PushSubscription) =>
|
||||
sub.endpoint.endsWith('device-a')
|
||||
? Promise.reject(new webpush.WebPushError('not found', 404))
|
||||
: Promise.resolve(undefined),
|
||||
)
|
||||
|
||||
await sendPushOnDefectFiled(db, vapid, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'x',
|
||||
})
|
||||
|
||||
const remaining = (await listSubscriptions(db)).map((s) => s.endpoint)
|
||||
expect(remaining).toEqual(['https://push.example/device-b'])
|
||||
})
|
||||
|
||||
it('keeps the subscription and does not throw on a transient send error', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await seedTwoDevices()
|
||||
sendNotification.mockImplementation((sub: webpush.PushSubscription) =>
|
||||
sub.endpoint.endsWith('device-a')
|
||||
? Promise.reject(new webpush.WebPushError('server error', 500))
|
||||
: Promise.resolve(undefined),
|
||||
)
|
||||
|
||||
await expect(
|
||||
sendPushOnDefectFiled(db, vapid, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'x',
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
// A 500 is transient — both devices are kept for the next defect.
|
||||
expect((await listSubscriptions(db)).length).toBe(2)
|
||||
})
|
||||
|
||||
it('does nothing when there are no subscriptions', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await sendPushOnDefectFiled(db, vapid, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'x',
|
||||
})
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when VAPID keys are not configured', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
await seedTwoDevices()
|
||||
|
||||
await sendPushOnDefectFiled(
|
||||
db,
|
||||
{ subject: 'mailto:owner@theodo.com', publicKey: '', privateKey: '' },
|
||||
{ sectionId: 'macroplan', projectId: project.id, verbatim: 'x' },
|
||||
)
|
||||
|
||||
expect(setVapidDetails).not.toHaveBeenCalled()
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
92
tests/push-subscriptions.spec.ts
Normal file
92
tests/push-subscriptions.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
|
||||
import {
|
||||
saveSubscription,
|
||||
listSubscriptions,
|
||||
deleteSubscriptionByEndpoint,
|
||||
} from '../server/db/repositories/pushSubscriptions'
|
||||
|
||||
// T10/F8: the owner enrols one or more devices for Web Push. Subscriptions are
|
||||
// keyed by their push endpoint so re-subscribing the same device is idempotent,
|
||||
// distinct devices are kept side by side, and a 410 from the push service
|
||||
// prunes the stale one (the pruning itself happens when T11 sends).
|
||||
describe('push subscriptions repository', () => {
|
||||
let db: TestDb
|
||||
let pool: { end: () => Promise<void> }
|
||||
|
||||
beforeAll(async () => {
|
||||
const setup = await setupTestDb()
|
||||
db = setup.db
|
||||
pool = setup.pool
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll(db)
|
||||
})
|
||||
|
||||
const subA = {
|
||||
endpoint: 'https://push.example/device-a',
|
||||
p256dh: 'p256dh-a',
|
||||
auth: 'auth-a',
|
||||
reporterEmail: 'owner@theodo.com',
|
||||
}
|
||||
const subB = {
|
||||
endpoint: 'https://push.example/device-b',
|
||||
p256dh: 'p256dh-b',
|
||||
auth: 'auth-b',
|
||||
reporterEmail: 'owner@theodo.com',
|
||||
}
|
||||
|
||||
it('stores a subscription', async () => {
|
||||
await saveSubscription(db, subA)
|
||||
|
||||
const all = await listSubscriptions(db)
|
||||
expect(all).toHaveLength(1)
|
||||
expect(all[0]).toMatchObject({
|
||||
endpoint: subA.endpoint,
|
||||
p256dh: subA.p256dh,
|
||||
auth: subA.auth,
|
||||
reporterEmail: subA.reporterEmail,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a second device as a separate subscription', async () => {
|
||||
await saveSubscription(db, subA)
|
||||
await saveSubscription(db, subB)
|
||||
|
||||
const endpoints = (await listSubscriptions(db)).map((s) => s.endpoint).sort()
|
||||
expect(endpoints).toEqual([subA.endpoint, subB.endpoint])
|
||||
})
|
||||
|
||||
it('re-subscribing the same endpoint updates in place rather than duplicating', async () => {
|
||||
await saveSubscription(db, subA)
|
||||
await saveSubscription(db, { ...subA, p256dh: 'rotated-key', auth: 'rotated-auth' })
|
||||
|
||||
const all = await listSubscriptions(db)
|
||||
expect(all).toHaveLength(1)
|
||||
expect(all[0]).toMatchObject({ p256dh: 'rotated-key', auth: 'rotated-auth' })
|
||||
})
|
||||
|
||||
it('prunes a subscription by endpoint (the 410 path)', async () => {
|
||||
await saveSubscription(db, subA)
|
||||
await saveSubscription(db, subB)
|
||||
|
||||
const removed = await deleteSubscriptionByEndpoint(db, subA.endpoint)
|
||||
|
||||
expect(removed).toBe(1)
|
||||
expect((await listSubscriptions(db)).map((s) => s.endpoint)).toEqual([subB.endpoint])
|
||||
})
|
||||
|
||||
it('pruning an unknown endpoint is a no-op', async () => {
|
||||
await saveSubscription(db, subA)
|
||||
|
||||
const removed = await deleteSubscriptionByEndpoint(db, 'https://push.example/gone')
|
||||
|
||||
expect(removed).toBe(0)
|
||||
expect(await listSubscriptions(db)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
52
tests/subscriptions.spec.ts
Normal file
52
tests/subscriptions.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { type } from 'arktype'
|
||||
import { isOwner } from '../server/utils/isOwner'
|
||||
import { PushSubscriptionInput } from '../server/utils/pushSubscriptionInput'
|
||||
|
||||
// T10: only the configured owner may enrol for push (decided owner-only).
|
||||
describe('isOwner', () => {
|
||||
it('recognises the configured owner, case-insensitively', () => {
|
||||
expect(isOwner('Owner@Theodo.com', 'owner@theodo.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('denies anyone who is not the owner', () => {
|
||||
expect(isOwner('someone@theodo.com', 'owner@theodo.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('denies everyone when no owner is configured', () => {
|
||||
expect(isOwner('owner@theodo.com', '')).toBe(false)
|
||||
expect(isOwner('owner@theodo.com', undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('denies when the email is missing', () => {
|
||||
expect(isOwner(undefined, 'owner@theodo.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The body is the browser's PushSubscription.toJSON() shape, untrusted.
|
||||
describe('PushSubscriptionInput', () => {
|
||||
const valid = {
|
||||
endpoint: 'https://fcm.googleapis.com/fcm/send/abc123',
|
||||
keys: { p256dh: 'BNc...key', auth: 'tok' },
|
||||
}
|
||||
|
||||
it('accepts a well-formed browser subscription', () => {
|
||||
expect(PushSubscriptionInput(valid)).toMatchObject(valid)
|
||||
})
|
||||
|
||||
it('rejects a non-url endpoint', () => {
|
||||
expect(PushSubscriptionInput({ ...valid, endpoint: 'not-a-url' }) instanceof type.errors).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a missing keys object', () => {
|
||||
expect(PushSubscriptionInput({ endpoint: valid.endpoint }) instanceof type.errors).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an empty key', () => {
|
||||
expect(
|
||||
PushSubscriptionInput({ ...valid, keys: { p256dh: '', auth: 'tok' } }) instanceof type.errors,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user