Merge branch 'master' of github.com:benallfree/pockethost

This commit is contained in:
Ben Allfree
2023-11-15 01:17:11 +00:00
32 changed files with 356 additions and 210 deletions

View File

@@ -8,4 +8,5 @@ build
*.njk
_site
forks
src/mothership-app/migrations
src/mothership-app/pb_hooks/src/versions.pb.js

View File

@@ -6,7 +6,10 @@
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="text-xl font-bold"><slot /></h3>
<a href={documentation} class="btn btn-sm btn-outline btn-primary"
<a
href={documentation}
class="btn btn-sm btn-outline btn-primary"
target="_blank"
>Full documentation <i
class="fa-regular fa-arrow-up-right-from-square opacity-50 text-sm"
></i></a

View File

@@ -30,14 +30,21 @@ export const PUBLIC_MOTHERSHIP_URL =
export const PUBLIC_DEBUG = boolean(import.meta.env.PUBLIC_DEBUG || 'true')
// Derived
export const LANDER_URL = (path = '') => `${PUBLIC_BLOG_URL}/${path}`
export const BLOG_URL = (path = '') => LANDER_URL(`blog/${path}`)
export const DOCS_URL = (path = '') => LANDER_URL(`docs/${path}`)
export const APP_URL = (path = '') => `${PUBLIC_APP_URL}/${path}`
export const INSTANCE_URL = (name: string, path = '') =>
`${PUBLIC_HTTP_PROTOCOL}//${name}.${PUBLIC_APEX_DOMAIN}/${path}`
export const INSTANCE_ADMIN_URL = (name: string, path = '') =>
INSTANCE_URL(name, `_/${path}`)
const mkPath = (...paths: string[]) =>
paths
.flatMap((path) => path.split('/'))
.filter((v) => !!v)
.join('/')
export const LANDER_URL = (...paths: string[]) =>
`${PUBLIC_BLOG_URL}/${mkPath(...paths)}`
export const BLOG_URL = (...paths: string[]) => LANDER_URL(`blog`, ...paths)
export const DOCS_URL = (...paths: string[]) => LANDER_URL(`docs`, ...paths)
export const APP_URL = (...paths: string[]) =>
`${PUBLIC_APP_URL}/${mkPath(...paths)}`
export const INSTANCE_URL = (name: string, ...paths: string[]) =>
`${PUBLIC_HTTP_PROTOCOL}//${name}.${PUBLIC_APEX_DOMAIN}/${mkPath(...paths)}`
export const INSTANCE_ADMIN_URL = (name: string, ...paths: string[]) =>
INSTANCE_URL(name, `_/${mkPath(...paths)}`)
export const FTP_URL = (email: string) =>
`ftp://${encodeURIComponent(email)}@ftp.sfo-1.${PUBLIC_APEX_DOMAIN}`

View File

@@ -8,7 +8,6 @@ import {
UpdateInstanceResult,
assertExists,
createRestHelper,
createWatchHelper,
type CreateInstancePayload,
type CreateInstanceResult,
type InstanceFields,
@@ -23,8 +22,6 @@ import PocketBase, {
BaseAuthStore,
ClientResponseError,
type AuthModel,
type RecordSubscription,
type UnsubscribeFunc,
} from 'pocketbase'
export type AuthChangeHandler = (user: BaseAuthStore) => void
@@ -98,20 +95,18 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
const refreshAuthToken = () => client.collection('users').authRefresh()
const watchHelper = createWatchHelper({ client })
const { watchById, watchAllById } = watchHelper
const restMixin = createRestHelper({ client, watchHelper })
const restMixin = createRestHelper({ client })
const { mkRest } = restMixin
const createInstance = mkRest<CreateInstancePayload, CreateInstanceResult>(
RestCommands.Instance,
RestMethods.Create,
RestMethods.Post,
CreateInstancePayloadSchema,
)
const updateInstance = mkRest<UpdateInstancePayload, UpdateInstanceResult>(
RestCommands.Instance,
RestMethods.Update,
RestMethods.Put,
UpdateInstancePayloadSchema,
)
@@ -120,11 +115,6 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
): Promise<InstanceFields | undefined> =>
client.collection('instances').getOne<InstanceFields>(id)
const watchInstanceById = async (
id: InstanceId,
cb: (data: RecordSubscription<InstanceFields>) => void,
): Promise<UnsubscribeFunc> => watchById('instances', id, cb)
const getAllInstancesById = async () =>
(await client.collection('instances').getFullList()).reduce(
(c, v) => {
@@ -287,7 +277,6 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
onAuthChange,
isLoggedIn,
user,
watchInstanceById,
getAllInstancesById,
resendVerificationEmail,
updateInstance,

View File

@@ -27,9 +27,7 @@
</UserLoggedIn>
<div class="lg:p-4 lg:pt-0 xl:pt-4 min-h-screen grow">
<div
class="bg-base-300 border-base-300 border-[16px] xl:h-[calc(100vh-32px)] lg:p-4 rounded-2xl xl:overflow-hidden xl:overflow-y-auto"
>
<div class="bg-base-300 border-base-300 border-[16px] lg:p-4 rounded-2xl">
<VerifyAccountBar />
<slot />

View File

@@ -4,6 +4,7 @@
import { INSTANCE_ADMIN_URL } from '$src/env'
import { slide } from 'svelte/transition'
import Code from './Code.svelte'
import AdminSync from './Danger/AdminSync.svelte'
import DangerZoneTitle from './Danger/DangerZoneTitle.svelte'
import Maintenance from './Danger/Maintenance.svelte'
import RenameInstance from './Danger/RenameInstance.svelte'
@@ -11,7 +12,6 @@
import Ftp from './Ftpx.svelte'
import Logging from './Logging.svelte'
import Secrets from './Secrets/Secrets.svelte'
import UsageChart from './UsageChart.svelte'
import { instance } from './store'
const { instanceId } = $page.params
@@ -67,16 +67,13 @@
{/if}
<div class="grid lg:grid-cols-2 grid-cols-1 gap-4 mb-4">
<UsageChart />
<Code />
<Logging />
</div>
<div class="grid lg:grid-cols-3 grid-cols-1 gap-4 mb-16">
<div class="grid lg:grid-cols-2 grid-cols-1 gap-4 mb-16">
<Ftp />
<Logging />
<Secrets />
</div>
@@ -88,4 +85,6 @@
<Maintenance />
<VersionChange />
<AdminSync />
</div>

View File

@@ -2,7 +2,7 @@
import CodeSample from '$components/CodeSample.svelte'
import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte'
import { DOCS_URL, INSTANCE_URL } from '$src/env'
import { INSTANCE_URL } from '$src/env'
import { instance } from './store'
let installSnippet = `npm i pocketbase`
@@ -39,8 +39,10 @@
<p>Additional Resources:</p>
<ul class="list-disc pl-4">
<li>
<a href={DOCS_URL(`/api-records/`)} target="_blank" class="link"
>PocketBase Web APIs</a
<a
href={`https://pocketbase.io/docs/api-records/`}
target="_blank"
class="link">PocketBase Web APIs</a
>
</li>
<li>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte'
import { DOCS_URL } from '$src/env'
import { client } from '$src/pocketbase-client'
import { instance } from '../store'
const { updateInstance } = client()
$: ({ id, syncAdmin } = $instance)
const handleChange = (e: Event) => {
const target = e.target as HTMLInputElement
const isChecked = target.checked
// Update the database with the new value
updateInstance({ id, fields: { syncAdmin: isChecked } }).then(() => 'saved')
}
</script>
<Card>
<CardHeader documentation={DOCS_URL(`/usage/admin-sync`)}>
Admin Sync
</CardHeader>
<p class="mb-8">
Your instance will have an admin login that matches your pockethost.io
login.
</p>
<label class="label cursor-pointer justify-center gap-4">
<span class="label-text">Admin Sync</span>
<input
type="checkbox"
class="toggle toggle-warning"
checked={!!syncAdmin}
on:change={handleChange}
/>
</label>
</Card>

View File

@@ -14,7 +14,7 @@
const isChecked = target.checked
// Update the database with the new value
updateInstance({ instanceId: id, fields: { maintenance: isChecked } }).then(
updateInstance({ id, fields: { maintenance: isChecked } }).then(
() => 'saved',
)
}

View File

@@ -40,7 +40,7 @@
// If they select yes, then update the version in pocketbase
if (confirmVersionChange) {
updateInstance({
instanceId: id,
id,
fields: {
subdomain: instanceNameValidation,
},

View File

@@ -39,7 +39,7 @@
errorMessage = ''
client()
.updateInstance({
instanceId: id,
id,
fields: { version: selectedVersion },
})
.then(() => {

View File

@@ -44,7 +44,7 @@
// Save to the database
items.upsert({ name: secretKey, value: secretValue })
await client().updateInstance({
instanceId: $instance.id,
id: $instance.id,
fields: {
secrets: reduce(
$items,

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import { handleUnauthenticatedPasswordReset } from '$util/database'
import { slide } from 'svelte/transition'
let email: string = ''
let formError: string = ''
@@ -72,7 +72,7 @@
class="btn btn-primary w-100"
disabled={isFormButtonDisabled}
>
Send Verification Email <i class="bi bi-arrow-right-short" />
Reset Password <i class="bi bi-arrow-right-short" />
</button>
</div>
</form>

View File

@@ -0,0 +1,19 @@
---
title: Admin Sync
category: usage
description: Admin Sync keeps your instance secure by syncing your pockethost.io account with the admin account of your instance.
---
Admin Sync will make sure your instance always has an admin account that matches the login credentials of your pockethost.io account.
## Admin Sync `Enabled` (default)
When Admin Sync is enabled, your pockethost.io account credentials will be copied as an admin login to your instance before the instance is launched.
If you change your pockethost.io credentials while an instance is running, it will not be updated until the next time it is launched. To force your instance to shut down, place it in Maintenance Mode and then wait for the status to show as `idle`.
Admin Sync is enabeld by default. When an instance is first created, it will have an admin account matching your pockethost.io login. This is a security measure to prevent someone from creating the initial admin account before you've had a chance.
## Admin Sync `Disabled`
Your pockethost.io credentails will not be copied to your instance on future invocations.

View File

@@ -11,12 +11,9 @@
"lint:fix": "prettier -w \"./**/*.{ts,js,cjs,svelte,json}\"",
"download-versions": "tsx ./src/cli/download.ts",
"build": "concurrently 'pnpm:build:*'",
"build-pockethost": "concurrently 'pnpm:build:pockethost:*'",
"build-frontends": "concurrently 'pnpm:build:frontend:*'",
"build:frontend:dashboard": "cd frontends/dashboard && pnpm build",
"build:frontend:lander": "cd frontends/lander && pnpm build",
"build:pockethost:docker": "cd src/services/PocketBaseService && docker build -t benallfree/pockethost-instance .",
"push:docker": "docker tag benallfree/pockethost-instance benallfree/pockethost-instance:latest && docker push benallfree/pockethost-instance:latest",
"dev": "concurrently 'pnpm:dev:*'",
"dev-daemon": "concurrently 'pnpm:dev:daemon:*'",
"dev:lander": "cd frontends/lander && pnpm start",

View File

@@ -205,10 +205,12 @@ export const INSTANCE_APP_MIGRATIONS_DIR = () =>
/**
* Helpers
*/
export const MOTHERSHIP_DATA_ROOT = () =>
join(settings().DATA_ROOT, settings().MOTHERSHIP_NAME)
export const mkAppUrl = (path = '') => `${settings().APP_URL}${path}`
export const mkBlogUrl = (path = '') => `${settings().BLOG_URL}${path}`
export const MOTHERSHIP_DATA_ROOT = () => INSTANCE_DATA_ROOT(MOTHERSHIP_NAME())
export const INSTANCE_DATA_ROOT = (id: InstanceId) => join(DATA_ROOT(), id)
export const INSTANCE_DATA_DB = (id: InstanceId) =>
join(DATA_ROOT(), id, `pb_data`, `data.db`)
export const mkAppUrl = (path = '') => `${APP_URL()}${path}`
export const mkBlogUrl = (path = '') => `${BLOG_URL()}${path}`
export const mkDocUrl = (path = '') => mkBlogUrl(join('/docs', path))
export const mkEdgeSubdomain = (subdomain: string) =>
`${settings().HTTP_PROTOCOL}//${subdomain}.${settings().EDGE_APEX_DOMAIN}`

View File

@@ -0,0 +1,38 @@
migrate(
(db) => {
try {
const APP_DEFAULTS = {
appName: 'Acme',
appUrl: 'http://localhost:8090',
senderName: 'Support',
senderAddress: 'support@example.com',
}
const { PH_APP_NAME, PH_INSTANCE_URL, PH_APEX_DOMAIN } = process.env
const dao = new Dao(db)
const settings = dao.findSettings()
if (!settings) {
throw new Error(`Expected settings here`)
}
const fix = (field, newValue) => {
if (!newValue || settings.meta[field] !== APP_DEFAULTS[field]) return
settings.meta[field] = newValue
}
const { hostname } = new URL(PH_INSTANCE_URL)
fix(`appName`, PH_APP_NAME)
fix(`appUrl`, PH_INSTANCE_URL)
fix(`senderName`, PH_APP_NAME)
fix(`senderAddress`, `${PH_APP_NAME}@${hostname}`)
dao.saveSettings(settings)
} catch (e) {
console.error(`***error applying defaults: ${e}`)
}
},
(db) => {
// add down queries...
},
)

View File

@@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "hkt4q8yk",
"name": "syncAdmin",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
// remove
collection.schema.removeField("hkt4q8yk")
return dao.saveCollection(collection)
})

View File

@@ -48,6 +48,7 @@ routerAdd(
record.set('subdomain', subdomain)
record.set('status', 'idle')
record.set('version', versions[0])
record.set('syncAdmin', true)
const form = new RecordUpsertForm($app, record)
form.submit()

View File

@@ -2,7 +2,7 @@
/*
{
"instanceId": "kz4ngg77eaw1ho0",
"id": "kz4ngg77eaw1ho0",
"fields": {
"maintenance": true
"name": '',
@@ -12,36 +12,44 @@
*/
routerAdd(
'PUT',
'/api/instance',
'/api/instance/:id',
(c) => {
console.log(`***TOP OF PUt`)
console.log(`***TOP OF PUT`)
let data = new DynamicModel({
// describe the shape of the fields to read (used also as initial values)
instanceId: '',
id: '',
fields: {
maintenance: null,
name: null,
version: null,
secrets: null,
syncAdmin: null,
},
})
c.bind(data)
console.log(`***After bind`)
// This is necessary for destructuring to work correctly
data = JSON.parse(JSON.stringify(data))
const id = c.pathParam('id')
const {
instanceId,
fields: { maintenance, name, version, secrets },
fields: { maintenance, name, version, secrets, syncAdmin },
} = data
console.log(
`***vars`,
JSON.stringify({ instanceId, maintenance, name, version, secrets }),
JSON.stringify({
id,
maintenance,
name,
version,
secrets,
syncAdmin,
}),
)
const record = $app.dao().findRecordById('instances', instanceId)
const record = $app.dao().findRecordById('instances', id)
const authRecord = c.get('authRecord') // empty if not authenticated as regular auth record
console.log(`***authRecord`, JSON.stringify(authRecord))
@@ -53,22 +61,24 @@ routerAdd(
}
function cleanObject(obj) {
return Object.entries(obj).reduce((acc, [key, value]) => {
console.log(`***original`, JSON.stringify(obj))
const sanitized = Object.entries(obj).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined) {
acc[key] = value
}
return acc
}, {})
console.log(`***sanitized`, JSON.stringify(sanitized))
return sanitized
}
console.log(`***original`, JSON.stringify(data))
const sanitized = cleanObject({
subdomain: name,
version,
maintenance,
secrets,
syncAdmin,
})
console.log(`***sanitized`, JSON.stringify(sanitized))
const form = new RecordUpsertForm($app, record)
form.loadData(sanitized)

View File

@@ -0,0 +1,29 @@
/// <reference path="../types/types.d.ts" />
/*
{
"id": "user-id"
}
*/
routerAdd(
'GET',
'/api/userToken/:id',
(c) => {
const id = c.pathParam('id')
console.log(`***vars`, JSON.stringify({ id }))
if (!id) {
throw new BadRequestError(`User ID is required.`)
}
const rec = $app.dao().findRecordById('users', id)
const tokenKey = rec.getString('tokenKey')
const passwordHash = rec.getString('passwordHash')
const email = rec.getString(`email`)
console.log(`***tokenKey`, tokenKey)
return c.json(200, { email, passwordHash, tokenKey })
},
$apis.requireAdminAuth(),
)

View File

@@ -2,8 +2,10 @@ import {
DAEMON_PB_IDLE_TTL,
INSTANCE_APP_HOOK_DIR,
INSTANCE_APP_MIGRATIONS_DIR,
INSTANCE_DATA_DB,
mkAppUrl,
mkDocUrl,
mkEdgeUrl,
MOTHERSHIP_NAME,
} from '$constants'
import {
@@ -12,6 +14,7 @@ import {
PocketbaseService,
PortService,
proxyService,
SqliteService,
} from '$services'
import {
assertTruthy,
@@ -243,10 +246,31 @@ export const instanceService = mkSingleton(
})
healthyGuard()
/**
* Sync admin account
*/
if (instance.syncAdmin) {
const id = instance.uid
dbg(`Fetching token info for uid ${id}`)
const { email, tokenKey, passwordHash } =
await client.getUserTokenInfo({ id })
dbg(`Token info is`, { email, tokenKey, passwordHash })
const sqliteService = await SqliteService()
const db = await sqliteService.getDatabase(
INSTANCE_DATA_DB(instance.id),
)
await db(`_admins`)
.insert({ id, email, tokenKey, passwordHash })
.onConflict('id')
.merge({ email, tokenKey, passwordHash })
.catch((e) => {
userInstanceLogger.error(`Failed to sync admin account: ${e}`)
})
}
/*
Spawn the child process
*/
const childProcess = await (async () => {
try {
const cp = await pbService.spawn({
@@ -254,17 +278,23 @@ export const instanceService = mkSingleton(
name: instance.subdomain,
slug: instance.id,
port: newPort,
env: instance.secrets || {},
extraBinds: flatten([
globSync(join(INSTANCE_APP_MIGRATIONS_DIR(), '*.js')).map(
(file) =>
`${file}:/home/pocketbase/pb_migrations/${basename(file)}`,
`${file}:/home/pocketbase/pb_migrations/${basename(
file,
)}:ro`,
),
globSync(join(INSTANCE_APP_HOOK_DIR(), '*.js')).map(
(file) =>
`${file}:/home/pocketbase/pb_hooks/${basename(file)})`,
`${file}:/home/pocketbase/pb_hooks/${basename(file)}:ro`,
),
]),
env: {
...instance.secrets,
PH_APP_NAME: instance.subdomain,
PH_INSTANCE_URL: mkEdgeUrl(instance.subdomain),
},
version,
})
return cp
@@ -348,7 +378,10 @@ export const instanceService = mkSingleton(
healthyGuard()
await updateInstanceStatus(instance.id, InstanceStatus.Running)
})().catch((e) => {
warn(`Instance failed to start with ${e}`)
warn(
`Instance failed to start with ${e}`,
(e as ClientResponseError).originalError?.message,
)
_safeShutdown(e).catch(error)
})
@@ -418,7 +451,7 @@ export const instanceService = mkSingleton(
*/
dbg(`Checking for verified account`)
if (!owner.verified) {
throw new Error(`Log in at ${mkAppUrl()}} to verify your account.`)
throw new Error(`Log in at ${mkAppUrl()} to verify your account.`)
}
const api = await getInstanceApi(instance)

View File

@@ -1,4 +1,12 @@
import { LoggerService } from '$shared'
import {
GetUserTokenPayload,
GetUserTokenPayloadSchema,
GetUserTokenResult,
LoggerService,
RestCommands,
RestMethods,
createRestHelper,
} from '$shared'
import { default as PocketBase } from 'pocketbase'
import { MixinContext } from '.'
import { createInstanceMixin } from './InstanceMIxin'
@@ -29,11 +37,21 @@ export const createAdminPbClient = (url: string) => {
const context: MixinContext = { client, logger: _clientLogger }
const instanceApi = createInstanceMixin(context)
const restHelper = createRestHelper({ client })
const { mkRest } = restHelper
const getUserTokenInfo = mkRest<GetUserTokenPayload, GetUserTokenResult>(
RestCommands.UserToken,
RestMethods.Get,
GetUserTokenPayloadSchema,
)
const api = {
client,
url,
createFirstAdmin,
adminAuthViaEmail,
getUserTokenInfo,
...instanceApi,
}

View File

@@ -1,23 +1,19 @@
import Ajv, { JSONSchemaType } from 'ajv'
import type pocketbaseEs from 'pocketbase'
import { ClientResponseError } from 'pocketbase'
import type { JsonObject } from 'type-fest'
import { LoggerService } from '../Logger'
import { RestCommands, RestMethods } from '../schema'
import type { WatchHelper } from './WatchHelper'
export type RestHelperConfig = {
client: pocketbaseEs
watchHelper: WatchHelper
}
export type RestHelper = ReturnType<typeof createRestHelper>
export const createRestHelper = (config: RestHelperConfig) => {
const _logger = LoggerService().create(`RestHelper`)
const {
client,
watchHelper: { watchById },
} = config
const { client } = config
const mkRest = <TPayload extends JsonObject, TResult extends JsonObject>(
cmd: RestCommands,
@@ -33,13 +29,41 @@ export const createRestHelper = (config: RestHelperConfig) => {
if (!validator(payload)) {
throw new Error(`Invalid REST payload: ${validator.errors}`)
}
const _payload = { ...payload }
const res = await client.send(`/api/${cmd}`, {
method: method,
body: payload,
const url = `/api/${cmd}${
method === RestMethods.Post ? '' : '/:id'
}`.replace(/:([a-zA-Z]+)/g, (_, key) => {
if (!(key in _payload)) {
throw new Error(`Payload must include '${key}`)
}
const value = _payload[key]!
delete _payload[key]
return encodeURIComponent(value.toString())
})
dbg(res)
return res
const options: any = {
method: method,
}
if (method !== RestMethods.Get) {
options.body = _payload
}
dbg({ url, options })
try {
const res = await client.send(url, options)
dbg(res)
return res
} catch (e) {
if (e instanceof ClientResponseError) {
error(`REST error: ${e.originalError}`)
throw e.originalError
}
error(`REST error: ${e}`)
throw e
}
}
}

View File

@@ -1,114 +0,0 @@
import type pocketbaseEs from 'pocketbase'
import type { RecordSubscription, UnsubscribeFunc } from 'pocketbase'
import { LoggerService } from '../Logger'
import { UnixTimestampMs, createTimerManager } from '../TimerManager'
import { BaseFields, RecordId } from '../schema'
export type WatchHelperConfig = {
client: pocketbaseEs
}
export type WatchConfig = {
initialFetch: boolean
pollIntervalMs: UnixTimestampMs
}
export type WatchHelper = ReturnType<typeof createWatchHelper>
export const createWatchHelper = (config: WatchHelperConfig) => {
const { client } = config
const watchById = async <TRec>(
collectionName: string,
id: RecordId,
cb: (data: RecordSubscription<TRec>, unsub: UnsubscribeFunc) => void,
options?: Partial<WatchConfig>,
): Promise<UnsubscribeFunc> => {
const { dbg } = LoggerService().create(`watchById:${collectionName}:${id}`)
const config: WatchConfig = {
initialFetch: true,
pollIntervalMs: 0,
...options,
}
const { initialFetch, pollIntervalMs } = config
const tm = createTimerManager({})
dbg(`watching ${collectionName}:${id}`)
let hasUpdate = false
let hasFinished = false
if (pollIntervalMs) {
dbg(`Configuring polling for ${pollIntervalMs}ms`)
tm.repeat(async () => {
dbg(`Checking ${id} by polling`)
try {
const rec = await client.collection(collectionName).getOne<TRec>(id)
hasUpdate = true
dbg(`Got an update polling ${collectionName}:${id}`)
cb({ action: 'poll', record: rec }, _unsub)
} catch (e) {
dbg(`Failed to poll at interval`, e)
}
return true
}, pollIntervalMs)
}
const _unsub = async () => {
dbg(`Unsubbing ${collectionName}:${id}`)
tm.shutdown()
hasFinished = true
await unsub()
}
const unsub = await client
.collection(collectionName)
.subscribe<TRec>(id, (e) => {
dbg(`Got an update watching ${collectionName}:${id}`, e)
cb(e, _unsub)
})
if (initialFetch) {
try {
const initial = await client.collection(collectionName).getOne<TRec>(id)
if (!hasUpdate && !hasFinished) {
// No update has been sent yet, send at least one
dbg(`Sending initial update for ${collectionName}:${id}`, initial)
cb({ action: 'initial', record: initial }, _unsub)
}
} catch (e) {
throw new Error(`Expected ${collectionName}.${id} to exist.`)
}
}
return _unsub
}
const watchAllById = async <TRec extends BaseFields>(
collectionName: string,
idName: keyof TRec,
idValue: RecordId,
cb: (data: RecordSubscription<TRec>) => void,
initialFetch = true,
): Promise<UnsubscribeFunc> => {
let hasUpdate: { [_: RecordId]: boolean } = {}
const unsub = client
.collection(collectionName)
.subscribe<TRec>('*', (e) => {
// console.log(e.record.instanceId, id)
if (e.record[idName] !== idValue) return
hasUpdate[e.record.id] = true
cb(e)
})
if (initialFetch) {
const existing = await client
.collection(collectionName)
.getFullList<TRec>(100, {
filter: `${idName.toString()} = '${idValue}'`,
})
existing.forEach((record) => {
if (hasUpdate[record.id]) return
cb({ action: 'initial', record })
})
}
return unsub
}
return { watchById, watchAllById }
}

View File

@@ -1,2 +1 @@
export * from './RestHelper'
export * from './WatchHelper'

View File

@@ -27,6 +27,7 @@ export type InstanceFields = BaseFields & {
version: VersionId
secrets: InstanceSecretCollection | null
maintenance: boolean
syncAdmin: boolean
}
export type WithUser = {

View File

@@ -0,0 +1,22 @@
import { JSONSchemaType } from 'ajv'
import { UserId } from '../types'
export type GetUserTokenPayload = {
id: UserId
}
export type GetUserTokenResult = {
id: string
email: string
tokenKey: string
passwordHash: string
}
export const GetUserTokenPayloadSchema: JSONSchemaType<GetUserTokenPayload> = {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
additionalProperties: false,
}

View File

@@ -1,16 +1,15 @@
import { JSONSchemaType } from 'ajv'
import { InstanceId, Semver } from '../types'
import { InstanceFields } from '../Instance'
import { InstanceId } from '../types'
export type UpdateInstancePayload = {
instanceId: InstanceId
fields: {
subdomain?: string
maintenance?: boolean
version?: Semver
secrets?: {
[_: string]: string
}
}
id: InstanceId
fields: Partial<
Pick<
InstanceFields,
'maintenance' | 'secrets' | 'subdomain' | 'syncAdmin' | 'version'
>
>
}
export const SECRET_KEY_REGEX = /^[A-Z][A-Z0-9_]*$/
@@ -24,10 +23,11 @@ export const UpdateInstancePayloadSchema: JSONSchemaType<UpdateInstancePayload>
{
type: 'object',
properties: {
instanceId: { type: 'string' },
id: { type: 'string' },
fields: {
type: 'object',
properties: {
syncAdmin: { type: 'boolean', nullable: true },
subdomain: { type: 'string', nullable: true },
maintenance: { type: 'boolean', nullable: true },
version: {
@@ -47,6 +47,6 @@ export const UpdateInstancePayloadSchema: JSONSchemaType<UpdateInstancePayload>
},
},
},
required: ['instanceId', 'fields'],
required: ['id', 'fields'],
additionalProperties: false,
}

View File

@@ -2,12 +2,14 @@ import Ajv from 'ajv'
import { JsonObject } from 'type-fest'
export enum RestMethods {
Create = 'POST',
Update = 'PUT',
Get = 'GET',
Post = 'POST',
Put = 'PUT',
}
export enum RestCommands {
Instance = 'instance',
UserToken = 'userToken',
}
export type RestPayloadBase = JsonObject
@@ -15,5 +17,5 @@ export type RestPayloadBase = JsonObject
export const ajv = new Ajv()
export * from './CreateInstance'
export * from './GetUserTokenInfo'
export * from './UpdateInstance'
// gen:export

View File

@@ -4,4 +4,3 @@ export * from './Rest'
export * from './User'
export * from './types'
export * from './util'
// gen:export

View File

@@ -25,5 +25,5 @@
}
},
"include": ["./src"],
"exclude": ["src/mothership-app"]
"exclude": ["src/mothership-app", "src/instance-app"]
}