Compare commits

...

10 commits

7 changed files with 474 additions and 34 deletions

55
deno.lock Normal file
View file

@ -0,0 +1,55 @@
{
"version": "3",
"packages": {
"specifiers": {
"jsr:@std/json@^0.223.0": "jsr:@std/json@0.223.0",
"jsr:@std/streams@^0.223.0": "jsr:@std/streams@0.223.0",
"npm:superjson@1.13.3": "npm:superjson@1.13.3"
},
"jsr": {
"@std/json@0.223.0": {
"integrity": "9a4a255931dd0397924c6b10bb6a72fe3e28ddd876b981ada2e3b8dd0764163f",
"dependencies": [
"jsr:@std/streams@^0.223.0"
]
},
"@std/streams@0.223.0": {
"integrity": "d6b28e498ced3960b04dc5d251f2dcfc1df244b5ec5a48dc23a8f9b490be3b99"
}
},
"npm": {
"copy-anything@3.0.5": {
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"dependencies": {
"is-what": "is-what@4.1.16"
}
},
"is-what@4.1.16": {
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"dependencies": {}
},
"superjson@1.13.3": {
"integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==",
"dependencies": {
"copy-anything": "copy-anything@3.0.5"
}
}
}
},
"remote": {
"https://deno.land/std@0.203.0/dotenv/load.ts": "0636983549b98f29ab75c9a22a42d9723f0a389ece5498fe971e7bb2556a12e2",
"https://deno.land/std@0.203.0/dotenv/mod.ts": "1da8c6d0e7f7d8a5c2b19400b763bc11739df24acec235dda7ea2cfd3d300057",
"https://deno.land/std@0.216.0/json/_common.ts": "6444c6ea166514eb379f778d9216c1d8eb159f97c753aeeb1fc44cd091e30544",
"https://deno.land/std@0.216.0/json/common.ts": "33f1a4f39a45e2f9f357823fd0b5cf88b63fb4784b8c9a28f8120f70a20b23e9",
"https://deno.land/std@0.216.0/json/concatenated_json_parse_stream.ts": "13a707615e03e5ea93ac81e5f00420e3b7764c4e3fa88043e63bbac87ebe62ce",
"https://deno.land/std@0.216.0/json/json_parse_stream.ts": "2740652ea73726cd0f2edc89188b35d64a1ec15dc8cf7fd87db52a0170bc182c",
"https://deno.land/std@0.216.0/json/json_stringify_stream.ts": "269633e63d4e38ab3ba31e76a4292d11b9eb3151e26ff4f49dee1c264b4878fa",
"https://deno.land/std@0.216.0/json/mod.ts": "acd3e39a6c68c70ee7fa93991a8a8f02d799f7394db3cef09594e2dd8fd69814",
"https://deno.land/std@0.216.0/streams/to_transform_stream.ts": "4c4836455ef89bab9ece55975ee3a819f07d3d8b0e43101ec7f4ed033c8a2b61"
},
"workspace": {
"dependencies": [
"jsr:@std/json@^0.223.0"
]
}
}

128
src/db/mod.ts Normal file
View file

@ -0,0 +1,128 @@
import { Credential, Group, Machine, Ressource, Service, User } from '@models'
//!TODO link ressources (get, list)
//!TODO Purge unused ressources (delete)
export class Db {
#kv: Deno.Kv
static async init(path?: string) {
const kv = await Deno.openKv(path ?? Deno.env.get('DB_PATH'))
return new Db(kv)
}
private constructor(kv: Deno.Kv) {
this.#kv = kv
}
get storage() {
return this.#kv
}
get prefix() {
return {
ressource: 'ressource',
}
}
get ressource() {
return {
credential: this.#ressourceStorage<Credential>('credential', Credential),
group: this.#ressourceStorage<Group>('group', Group),
machine: this.#ressourceStorage<Machine>('machine', Machine),
service: this.#ressourceStorage<Service>('service', Service),
user: this.#ressourceStorage<User>('user', User),
}
}
#ressourceStorage<T extends Ressource>(
type: RessourceType<T>,
Builder: RessourceBuilder<T>,
) {
return {
get: (ressource: Pick<T, 'uuid'>) =>
this.#get<T>(type, Builder, ressource),
set: (ressources: T[]) => this.#set<T>(type, ressources),
delete: (ressources: Pick<T, 'uuid'>[]) =>
this.#delete<T>(type, ressources),
list: (filter: (ressource: T) => boolean = () => true) =>
this.#list<T>(type, Builder, filter),
}
}
async #get<T extends Ressource>(
type: RessourceType<T>,
Builder: RessourceBuilder<T>,
entry: Pick<T, 'uuid'>,
): Promise<T> {
const json = await this.#kv.get<RessourceJson<T>>([
this.prefix.ressource,
type,
entry.uuid,
])
if (json.value) {
//@ts-expect-error Type union of Ressource types for Builder
return Builder.fromJSON(json.value)
}
throw new ReferenceError(
`no ressource.${type} was found with uuid: "${entry.uuid}"`,
)
}
#set<T extends Ressource>(type: RessourceType<T>, entries: T[]) {
const atomic = this.#kv.atomic()
for (const entry of entries) {
//! TODO check if `refs` exists
const key = [this.prefix.ressource, type, entry.uuid]
atomic.set(key, entry.toJSON())
}
return atomic.commit()
}
#delete<T extends Ressource>(
type: RessourceType<T>,
entries: Pick<T, 'uuid'>[],
): Promise<Deno.KvCommitResult | Deno.KvCommitError> {
const atomic = this.#kv.atomic()
for (const entry of entries) {
//! TODO check if `refs` exists
atomic.delete([this.prefix.ressource, type, entry.uuid])
}
return atomic.commit()
}
async *#list<T extends Ressource>(
type: RessourceType<T>,
Builder: RessourceBuilder<T>,
filter: (entry: T) => boolean,
): AsyncGenerator<T, void, void> {
const list = this.#kv.list<RessourceJson<T>>({
prefix: [this.prefix.ressource, type],
})
for await (const entry of list) {
const value = entry.value
//@ts-expect-error Type union of Ressource types for Builder
const ressource = Builder.fromJSON(value) as T
if (filter(ressource)) {
yield ressource
}
}
}
}
type RessourceType<T extends Ressource> = T extends Credential ? 'credential'
: T extends Group ? 'group'
: T extends Machine ? 'machine'
: T extends Service ? 'service'
: T extends User ? 'user'
: never
type RessourceBuilder<T extends Ressource> = T extends Credential
? typeof Credential
: T extends Group ? typeof Group
: T extends Machine ? typeof Machine
: T extends Service ? typeof Service
: T extends User ? typeof User
: never
type RessourceJson<T extends Ressource> = ReturnType<T['toJSON']>

11
src/models/mod.ts Normal file
View file

@ -0,0 +1,11 @@
export {
Ref,
type RefResolver,
type RefString,
} from '@/src/models/utils/ref.ts'
export { Credential } from '@models/credential.ts'
export { Group } from '@models/group.ts'
export { Machine } from '@models/machine.ts'
export type { Ressource } from '@models/ressource.ts'
export { Service } from '@models/service.ts'
export { User } from '@models/user.ts'

View file

@ -0,0 +1,105 @@
import { ToJson } from '@/types.ts'
import { Ressource } from '@models/ressource.ts'
export class Credential extends Ressource {
static fromJSON(
json: ToJson<Credential>,
): Credential {
return new Credential(json)
}
static create(
{ category, store, name }: Pick<Credential, 'category' | 'store' | 'name'>,
): Credential {
const { uuid, createdAt, updatedAt } = super.create({ name })
return new Credential({ uuid, createdAt, updatedAt, name, category, store })
}
#category: 'password' | 'ssh' | 'passkey'
#store: CredentialCategory<Credential['category']>
private constructor(
{ category, store, ...props }:
& Pick<Credential, 'category' | 'store'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>,
) {
super(props)
if (!['password', 'ssh', 'passkey'].includes(category)) {
throw new TypeError(
`category is "${category}" but ('password' | 'ssh' | 'passkey') is required`,
)
}
this.#category = category
this.#store = Object.freeze(store)
}
get type(): 'credential' {
return 'credential'
}
get category() {
return this.#category
}
get store() {
return Object.freeze(this.#store)
}
update(
props: Partial<Omit<Credential, 'type' | 'uuid' | 'createdAt'>>,
): Credential {
const { updatedAt } = super.update(props)
const credential = new Credential({ ...this, ...props, updatedAt })
return credential
}
toJSON() {
return {
...super.toJSON(),
type: this.type,
category: this.category,
store: Object.freeze(this.store),
} as const
}
toString(): string {
return `Credential (${JSON.stringify(this)})`
}
}
export interface Credential extends Ressource {
type: 'credential'
category: 'password' | 'ssh' | 'passkey'
store: Readonly<CredentialCategory<Credential['category']>>
}
type CredentialCategory<T extends 'password' | 'ssh' | 'passkey'> = T extends
'password' ? {
store: {
hash: string //hex or b64 of Uint8Array
alg: string
salt: string //hex or b64 of Uint8Array
}
}
: T extends 'ssh' ? {
store: {
publicKey: string
}
}
: T extends 'passkey' ? {
store: Record<string, unknown>
}
: never
/*
PassKey store:
{
publicKey: Uint8Array
keyId: string
transport: string
counter: number
}
*/
//new Uint8Array(Object.values(JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3])))))

113
src/models/src/machine.ts Normal file
View file

@ -0,0 +1,113 @@
import { ToJson } from '@/types.ts'
import { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Ref } from '@/src/models/utils/ref.ts'
export class Machine extends Ressource {
static fromJSON(
json: ToJson<Machine>,
): Machine {
const url = new URL(json.url)
const avatar = new URL(json.avatar)
const groups = json.groups.map((group) => Ref.fromString<Group>(group))
return new Machine({ ...json, url, avatar, groups })
}
static create(
{ tags, url, status, avatar, groups, ...props }:
& Pick<Machine, 'tags' | 'url' | 'status' | 'avatar' | 'groups'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>,
): Machine {
return new Machine({ ...props, tags, url, status, avatar, groups })
}
#tags: readonly string[]
#url: URL
#status:
| 'ready'
| 'busy'
| 'unavailable'
| 'discontinued'
| 'error'
| 'unknown'
#avatar: URL
#groups: readonly Ref<Group>[]
private constructor(
{ tags, url, status, avatar, groups, ...props }:
& Pick<Machine, 'tags' | 'url' | 'status' | 'avatar' | 'groups'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>,
) {
super(props)
this.#tags = Object.freeze(tags)
this.#url = new URL(url)
if (!['working', 'pending', 'ready', 'unavailable'].includes(status)) {
throw new TypeError(
`status is "${status}" but ('ready' | 'busy' | 'unavailable' | 'discontinued' | 'error' | 'unknown') is required`,
)
}
this.#status = status
this.#avatar = new URL(avatar)
this.#groups = Object.freeze(groups)
}
get type(): 'machine' {
return 'machine'
}
get tags() {
return this.#tags.slice()
}
get url() {
return new URL(this.#url)
}
get status() {
return this.#status
}
get avatar() {
return new URL(this.#avatar)
}
get groups() {
return this.#groups
}
update(
props: Partial<Omit<Machine, 'type' | 'uuid' | 'createdAt'>>,
): Machine {
const { updatedAt } = super.update(props)
const machine = new Machine({ ...this, ...props, updatedAt })
return machine
}
toJSON() {
return {
...super.toJSON(),
type: this.type,
tags: this.tags.slice(),
url: this.url.toJSON(),
status: this.status,
avatar: this.avatar.toJSON(),
groups: Object.freeze(this.groups.map((group) => group.toJSON())),
} as const
}
toString(): string {
return `Machine (${JSON.stringify(this)})`
}
}
export interface Machine extends Ressource {
type: 'machine'
tags: string[]
url: URL
status:
| 'ready'
| 'busy'
| 'unavailable'
| 'discontinued'
| 'error'
| 'unknown'
avatar: URL
groups: readonly Ref<Group>[]
}

View file

@ -37,6 +37,40 @@ export class Ref<T extends Ressource> extends String {
return new Ref<T>(Ref.parse(string))
}
static dbResolver(db: Db) {
return <T extends Ressource>(ref: RefString<T>) => {
const { type, uuid } = Ref.parse(ref)
return db.ressource[type].get({ uuid })
}
}
static restResolver(endpoint: string | URL) {
return async <T extends Ressource>(ref: RefString<T>) => {
const { type, uuid } = Ref.parse(ref)
const url = new URL(`${type}s/${uuid}`, endpoint)
const response = await fetch(url)
const json = await response.json()
if (type === 'user') {
return User.fromJSON(json)
}
if (type === 'machine') {
return Machine.fromJSON(json)
}
if (type === 'service') {
return Service.fromJSON(json)
}
if (type === 'group') {
return Group.fromJSON(json)
}
if (type === 'credential') {
return Credential.fromJSON(json)
}
throw new TypeError(`unknown ref type "${type}"`)
}
}
private constructor({ uuid, type }: { uuid: UUID; type: T['type'] }) {
super(Ref.#toString({ uuid, type }))
this.#type = type
@ -66,37 +100,3 @@ export class Ref<T extends Ressource> extends String {
return this.toString()
}
}
export function RefDbResolver(db: Db) {
return <T extends Ressource>(ref: RefString<T>) => {
const { type, uuid } = Ref.parse(ref)
return db.ressource[type].get({ uuid })
}
}
export function RefRestResolver(endpoint: string) {
return async <T extends Ressource>(ref: RefString<T>) => {
const { type, uuid } = Ref.parse(ref)
const url = new URL(`${type}/${uuid}`, endpoint)
const response = await fetch(url)
const json = await response.json()
if (type === 'user') {
return User.fromJSON(json)
}
if (type === 'machine') {
return Machine.fromJSON(json)
}
if (type === 'service') {
return Service.fromJSON(json)
}
if (type === 'group') {
return Group.fromJSON(json)
}
if (type === 'credential') {
return Credential.fromJSON(json)
}
throw new TypeError(`unknown ref type "${type}"`)
}
}

28
utils.ts Normal file
View file

@ -0,0 +1,28 @@
import { Login } from '@/types.ts'
export function toLogin({
firstname,
lastname,
}: {
firstname: string
lastname: string
}): Login {
return `${sanitizeString(firstname)}.${sanitizeString(lastname)}`
}
export function sanitizeString(str: string): string {
return str.toLocaleLowerCase().split('').map(sanitizeChar).join('')
}
function sanitizeChar(char: string): string {
//decompose unicode and remove diacritical marks
char = char.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
if (char.match(/[a-zA-Z0-9]|-|_/)) return char
return '_'
}
export const regex = {
uuidV4: /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/,
isoDateString: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$/,
mailAddress: /\S+@\S+\.\S+/,
}