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) - [Overview](development/overview.md)
- [Running Just the Frontend](development/frontend.md) - [Running Just the Frontend](development/frontend.md)
- [Running Everything](development/full-stack/index.md) - [Running Everything](development/full-stack/index.md)
- [Creating RPC Calls](development/rpc.md)
- [Production Deployment](development/production.md) - [Production Deployment](development/production.md)
## Release History ## 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) const validator = new Ajv().compile(schema)
return safeCatch( return safeCatch(
cmd, cmd,
logger(),
async ( async (
payload: TPayload, payload: TPayload,
cb?: (data: RecordSubscription<ConcreteRpcRecord>) => void cb?: (data: RecordSubscription<ConcreteRpcRecord>) => void

View File

@ -9,7 +9,8 @@ export type SaveSecretsPayload = {
} }
export type SaveSecretsResult = { export type SaveSecretsResult = {
status: 'saved' status: 'ok' | 'error'
message?: string
} }
export const SECRET_KEY_REGEX = /^[A-Z][A-Z0-9_]*$/ 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 { export enum RpcCommands {
CreateInstance = 'create-instance', CreateInstance = 'create-instance',
SaveSecrets = 'save-secrets', SaveSecrets = 'save-secrets',
SaveVersion = 'save-version',
// gen:enum // gen:enum
} }
export const RPC_COMMANDS = [ export const RPC_COMMANDS = [
RpcCommands.CreateInstance, RpcCommands.CreateInstance,
RpcCommands.SaveSecrets, RpcCommands.SaveSecrets,
RpcCommands.SaveVersion,
] ]
export enum RpcStatus { export enum RpcStatus {
@ -46,4 +48,5 @@ export const ajv = new Ajv()
export * from './CreateInstance' export * from './CreateInstance'
export * from './SaveSecrets' export * from './SaveSecrets'
export * from './SaveVersion'
// gen:export // gen:export

View File

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

View File

@ -22,6 +22,9 @@ import {
SaveSecretsPayload, SaveSecretsPayload,
SaveSecretsPayloadSchema, SaveSecretsPayloadSchema,
SaveSecretsResult, SaveSecretsResult,
SaveVersionPayload,
SaveVersionPayloadSchema,
SaveVersionResult,
SingletonBaseConfig, SingletonBaseConfig,
} from '@pockethost/common' } from '@pockethost/common'
import { forEachRight, map } from '@s-libs/micro-dash' 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>( registerCommand<SaveSecretsPayload, SaveSecretsResult>(
RpcCommands.SaveSecrets, RpcCommands.SaveSecrets,
SaveSecretsPayloadSchema, SaveSecretsPayloadSchema,
@ -82,7 +101,7 @@ export const instanceService = mkSingleton(
const { payload } = job const { payload } = job
const { instanceId, secrets } = payload const { instanceId, secrets } = payload
await client.updateInstance(instanceId, { secrets }) await client.updateInstance(instanceId, { secrets })
return { status: 'saved' } return { status: 'ok' }
} }
) )

View File

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

View File

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