feat: update pocketbase semver

This commit is contained in:
Ben Allfree 2023-06-14 13:28:45 -07:00
parent 465b95b5bc
commit 7b1d2d473c
11 changed files with 152 additions and 6 deletions

View File

@ -22,6 +22,7 @@
- [Overview](development/overview.md)
- [Running Just the Frontend](development/frontend.md)
- [Running Everything](development/full-stack/index.md)
- [Creating RPC Calls](development/rpc.md)
- [Production Deployment](development/production.md)
## Release History

View File

@ -0,0 +1,19 @@
# Creating RPC Calls
For security, PocketHost does not allow modification of records by the frontend PocketBase client. Instead, the frontend must send an rpc request which the backend will securely process. This allows for many security vulnerabilities to be addressed which PocketBase admin security rules cannot. In particular, PocketBase admin security rules fall short in these scenarios:
- When the incoming data cannot be validated declaratively
- When multiple records and/or tables must be updated as a transaction
- When side-effects (ie, other mutations) are required under specific conditions
Therefore, PocketHost uses an RPC pattern instead.
## Creating a new RPC Call
1. Create a new RPC call in `./packages/common/schema/Rpc`
2. Add frontend support in `./packages/pockethost.io/src/pocketbase/PocketbaseClient.ts` using the `mkRpc` command
3. Add backend support in (for example) `./packages/daemon/src/services/InstanceService/InstanceService.ts` using `registerCommand`
## Getting the result from an RPC call
RPC results are currently not supported. RPC commands are run asynchronously.

View File

@ -39,6 +39,7 @@ export const createRpcHelper = (config: RpcHelperConfig) => {
const validator = new Ajv().compile(schema)
return safeCatch(
cmd,
logger(),
async (
payload: TPayload,
cb?: (data: RecordSubscription<ConcreteRpcRecord>) => void

View File

@ -9,7 +9,8 @@ export type SaveSecretsPayload = {
}
export type SaveSecretsResult = {
status: 'saved'
status: 'ok' | 'error'
message?: string
}
export const SECRET_KEY_REGEX = /^[A-Z][A-Z0-9_]*$/

View File

@ -0,0 +1,24 @@
import { JSONSchemaType } from 'ajv'
import { InstanceId, Semver } from '../types'
export type SaveVersionPayload = {
instanceId: InstanceId
version: Semver
}
export type SaveVersionResult = {
status: 'ok' | 'error'
message?: string
}
export const SaveVersionPayloadSchema: JSONSchemaType<SaveVersionPayload> = {
type: 'object',
properties: {
instanceId: { type: 'string' },
version: {
type: 'string',
},
},
required: ['instanceId', 'version'],
additionalProperties: false,
}

View File

@ -6,12 +6,14 @@ export const RPC_COLLECTION = 'rpc'
export enum RpcCommands {
CreateInstance = 'create-instance',
SaveSecrets = 'save-secrets',
SaveVersion = 'save-version',
// gen:enum
}
export const RPC_COMMANDS = [
RpcCommands.CreateInstance,
RpcCommands.SaveSecrets,
RpcCommands.SaveVersion,
]
export enum RpcStatus {
@ -46,4 +48,5 @@ export const ajv = new Ajv()
export * from './CreateInstance'
export * from './SaveSecrets'
export * from './SaveVersion'
// gen:export

View File

@ -1,6 +1,7 @@
export type RecordId = string
export type UserId = RecordId
export type InstanceId = RecordId
export type Semver = string
export type InternalInstanceId = RecordId
export type Subdomain = string
export type Port = number

View File

@ -22,6 +22,9 @@ import {
SaveSecretsPayload,
SaveSecretsPayloadSchema,
SaveSecretsResult,
SaveVersionPayload,
SaveVersionPayloadSchema,
SaveVersionResult,
SingletonBaseConfig,
} from '@pockethost/common'
import { forEachRight, map } from '@s-libs/micro-dash'
@ -75,6 +78,22 @@ export const instanceService = mkSingleton(
}
)
const SEMVER_RE =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/
registerCommand<SaveVersionPayload, SaveVersionResult>(
RpcCommands.SaveVersion,
SaveVersionPayloadSchema,
async (rpc) => {
const { payload } = rpc
const { instanceId, version } = payload
if (version.match(SEMVER_RE) === null) {
return { status: `error`, message: `Semver must be a regex` }
}
await client.updateInstance(instanceId, { version })
return { status: 'ok' }
}
)
registerCommand<SaveSecretsPayload, SaveSecretsResult>(
RpcCommands.SaveSecrets,
SaveSecretsPayloadSchema,
@ -82,7 +101,7 @@ export const instanceService = mkSingleton(
const { payload } = job
const { instanceId, secrets } = payload
await client.updateInstance(instanceId, { secrets })
return { status: 'saved' }
return { status: 'ok' }
}
)

View File

@ -9,12 +9,15 @@ import {
RpcCommands,
safeCatch,
SaveSecretsPayloadSchema,
SaveVersionPayloadSchema,
type CreateInstancePayload,
type CreateInstanceResult,
type InstanceFields,
type InstanceId,
type SaveSecretsPayload,
type SaveSecretsResult,
type SaveVersionPayload,
type SaveVersionResult,
type UserFields,
type WorkerLogFields
} from '@pockethost/common'
@ -123,6 +126,11 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
SaveSecretsPayloadSchema
)
const saveVersion = mkRpc<SaveVersionPayload, SaveVersionResult>(
RpcCommands.SaveVersion,
SaveVersionPayloadSchema
)
const getInstanceById = safeCatch(
`getInstanceById`,
_logger,
@ -291,6 +299,7 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
user,
watchInstanceById,
getAllInstancesById,
resendVerificationEmail
resendVerificationEmail,
saveVersion
}
}

View File

@ -2,6 +2,7 @@
import ProvisioningStatus from '$components/ProvisioningStatus.svelte'
import { PUBLIC_APP_DOMAIN, PUBLIC_APP_PROTOCOL } from '$src/env'
import type { InstanceFields } from '@pockethost/common'
import RunningStatus from './RunningStatus.svelte'
export let instance: InstanceFields
@ -13,9 +14,7 @@
<h2>Overview</h2>
<ProvisioningStatus {status} />
Usage: {Math.ceil(instance.secondsThisMonth / 60)} mins
<div>
Running {version}
</div>
<RunningStatus {instance} />
<div>
Admin URL: <a href={`${url}/_`} target="_blank">{`${url}/_`}</a>
</div>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { PUBLIC_APP_DOMAIN, PUBLIC_APP_PROTOCOL } from '$src/env'
import { client } from '$src/pocketbase'
import type { InstanceFields } from '@pockethost/common'
export let instance: InstanceFields
const { subdomain, status, version } = instance
const url = `${PUBLIC_APP_PROTOCOL}://${subdomain}.${PUBLIC_APP_DOMAIN}`
let msg = ''
let _oldVersion = version
let _version = version
let editMode = false
const startEdit = () => {
_oldVersion = _version
editMode = true
}
const cancelEdit = () => {
_version = _oldVersion
editMode = false
}
const saveEdit = () => {
client()
.saveVersion({ instanceId: instance.id, version: _version })
.then(() => {
editMode = false
msg = 'saved'
})
.catch((e) => {
msg = e.message
})
}
</script>
<div>
Running {#if !editMode}
{_version}
<button
type="button"
class="btn btn-primary"
style="--bs-btn-padding-y: .05rem; --bs-btn-padding-x: .5rem; --bs-btn-font-size: .75rem;"
on:click={startEdit}
>
edit
</button>
{msg}
{/if}
{#if editMode}
<input type="text" bind:value={_version} />
<button
type="button"
class="btn btn-success"
style="--bs-btn-padding-y: .05rem; --bs-btn-padding-x: .5rem; --bs-btn-font-size: .75rem;"
on:click={saveEdit}
>
save
</button>
<button
type="button"
class="btn btn-danger"
style="--bs-btn-padding-y: .05rem; --bs-btn-padding-x: .5rem; --bs-btn-font-size: .75rem;"
on:click={cancelEdit}
>
cancel
</button>
{/if}
</div>