mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 01:04:14 +00:00
fix: bulk type safety improvements, added a couple of tests
This commit is contained in:
@@ -35,16 +35,6 @@
|
||||
invitesDisabled?: boolean
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
code: string
|
||||
available: number
|
||||
disabled: boolean
|
||||
forAccount: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
uses: Array<{ usedBy: string; usedAt: string }>
|
||||
}
|
||||
|
||||
let stats = $state<ServerStats | null>(null)
|
||||
let users = $state<User[]>([])
|
||||
let loading = $state(true)
|
||||
@@ -52,11 +42,6 @@
|
||||
let searchQuery = $state('')
|
||||
let usersCursor = $state<string | undefined>(undefined)
|
||||
|
||||
let invites = $state<Invite[]>([])
|
||||
let invitesLoading = $state(false)
|
||||
let invitesCursor = $state<string | undefined>(undefined)
|
||||
let showInvites = $state(false)
|
||||
|
||||
let selectedUser = $state<User | null>(null)
|
||||
let userActionLoading = $state(false)
|
||||
let userDetailLoading = $state(false)
|
||||
@@ -219,38 +204,6 @@
|
||||
logoChanged
|
||||
}
|
||||
|
||||
async function loadInvites(reset = false) {
|
||||
invitesLoading = true
|
||||
if (reset) {
|
||||
invites = []
|
||||
invitesCursor = undefined
|
||||
}
|
||||
try {
|
||||
const result = await api.getInviteCodes(session.accessJwt, {
|
||||
cursor: reset ? undefined : invitesCursor,
|
||||
limit: 25,
|
||||
})
|
||||
invites = reset ? result.codes : [...invites, ...result.codes]
|
||||
invitesCursor = result.cursor
|
||||
showInvites = true
|
||||
} catch {
|
||||
toast.error($_('admin.failedToLoadInvites'))
|
||||
} finally {
|
||||
invitesLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableInvite(code: string) {
|
||||
if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
|
||||
try {
|
||||
await api.disableInviteCodes(session.accessJwt, [code])
|
||||
invites = invites.map(i => i.code === code ? { ...i, disabled: true } : i)
|
||||
toast.success($_('admin.inviteDisabled'))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite'))
|
||||
}
|
||||
}
|
||||
|
||||
async function showUserDetail(user: User) {
|
||||
selectedUser = user
|
||||
userDetailLoading = true
|
||||
@@ -467,53 +420,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="invites-section">
|
||||
<h3>{$_('admin.inviteCodes')}</h3>
|
||||
<div class="section-actions">
|
||||
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
|
||||
{invitesLoading ? $_('common.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
|
||||
</button>
|
||||
</div>
|
||||
{#if showInvites}
|
||||
{#if invites.length === 0}
|
||||
<p class="empty">{$_('admin.noInvites')}</p>
|
||||
{:else}
|
||||
<ul class="invite-list">
|
||||
{#each invites as invite}
|
||||
<li class="invite-item" class:disabled-row={invite.disabled}>
|
||||
<div class="invite-info">
|
||||
<code class="invite-code">{invite.code}</code>
|
||||
<span class="invite-meta">
|
||||
{$_('admin.available')}: {invite.available} - {$_('admin.uses')}: {invite.uses.length} - {$_('admin.created')}: {formatDate(invite.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="invite-status">
|
||||
{#if invite.disabled}
|
||||
<span class="badge deactivated">{$_('admin.disabled')}</span>
|
||||
{:else if invite.available === 0}
|
||||
<span class="badge unverified">{$_('admin.exhausted')}</span>
|
||||
{:else}
|
||||
<span class="badge verified">{$_('admin.active')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="invite-actions">
|
||||
{#if !invite.disabled}
|
||||
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
|
||||
{$_('admin.disable')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if invitesCursor}
|
||||
<button type="button" class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
|
||||
{invitesLoading ? $_('common.loading') : $_('admin.loadMore')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if selectedUser}
|
||||
@@ -859,74 +765,6 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.invite-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.invite-item.disabled-row {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.invite-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.invite-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.invite-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { toast } from '../../lib/toast.svelte'
|
||||
import { formatDate } from '../../lib/date'
|
||||
import type { Session } from '../../lib/types/api'
|
||||
import Skeleton from '../Skeleton.svelte'
|
||||
|
||||
interface Props {
|
||||
session: Session
|
||||
@@ -15,6 +16,7 @@
|
||||
let codes = $state<InviteCode[]>([])
|
||||
let loading = $state(true)
|
||||
let creating = $state(false)
|
||||
let disablingCode = $state<string | null>(null)
|
||||
let createdCode = $state<string | null>(null)
|
||||
let createdCodeCopied = $state(false)
|
||||
let copiedCode = $state<string | null>(null)
|
||||
@@ -60,6 +62,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function disableCode(code: string) {
|
||||
if (!confirm($_('inviteCodes.disableConfirm', { values: { code } }))) return
|
||||
disablingCode = code
|
||||
try {
|
||||
await api.disableInviteCodes(session.accessJwt, [code])
|
||||
codes = codes.map(c => c.code === code ? { ...c, disabled: true } : c)
|
||||
toast.success($_('inviteCodes.disableSuccess'))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.disableFailed'))
|
||||
} finally {
|
||||
disablingCode = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyCode(code: string) {
|
||||
navigator.clipboard.writeText(code)
|
||||
copiedCode = code
|
||||
@@ -96,7 +112,19 @@
|
||||
<section class="list-section">
|
||||
<h2>{$_('inviteCodes.yourCodes')}</h2>
|
||||
{#if loading}
|
||||
<div class="loading">{$_('common.loading')}</div>
|
||||
<ul class="code-list">
|
||||
{#each Array(3) as _}
|
||||
<li class="code-item skeleton-item">
|
||||
<div class="code-main">
|
||||
<Skeleton variant="line" size="medium" />
|
||||
</div>
|
||||
<div class="code-meta">
|
||||
<Skeleton variant="line" size="short" />
|
||||
<Skeleton variant="line" size="tiny" />
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if codes.length === 0}
|
||||
<p class="empty">{$_('inviteCodes.noCodes')}</p>
|
||||
{:else}
|
||||
@@ -174,7 +202,6 @@
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-6);
|
||||
@@ -197,6 +224,10 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -224,6 +255,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--error-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-meta {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
import {
|
||||
P256PrivateKey,
|
||||
parsePrivateMultikey,
|
||||
parsePublicMultikey,
|
||||
Secp256k1PrivateKey,
|
||||
Secp256k1PrivateKeyExportable,
|
||||
} from "@atcute/crypto";
|
||||
import * as CBOR from "@atcute/cbor";
|
||||
import { fromBase16, toBase64Url } from "@atcute/multibase";
|
||||
import {
|
||||
fromBase16,
|
||||
fromBase58Btc,
|
||||
fromBase64Url,
|
||||
toBase64Url,
|
||||
} from "@atcute/multibase";
|
||||
|
||||
export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey;
|
||||
|
||||
@@ -36,6 +42,137 @@ export interface PlcOperationData {
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
type KeyCurve = "secp256k1" | "p256";
|
||||
|
||||
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const BASE58BTC_CHARSET_REGEX = /^[a-km-zA-HJ-NP-Z1-9]+$/;
|
||||
|
||||
const importRawBytes = (
|
||||
bytes: Uint8Array,
|
||||
curve: KeyCurve,
|
||||
): Promise<PrivateKey> =>
|
||||
curve === "p256"
|
||||
? P256PrivateKey.importRaw(bytes)
|
||||
: Secp256k1PrivateKey.importRaw(bytes);
|
||||
|
||||
const importFromMultikeyMatch = (
|
||||
match: ReturnType<typeof parsePrivateMultikey>,
|
||||
): Promise<PrivateKey> =>
|
||||
match.type === "p256"
|
||||
? P256PrivateKey.importRaw(match.privateKeyBytes)
|
||||
: Secp256k1PrivateKey.importRaw(match.privateKeyBytes);
|
||||
|
||||
const importJwk = async (
|
||||
json: string,
|
||||
_curve: KeyCurve,
|
||||
): Promise<PrivateKey> => {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid JWK: expected a JSON object");
|
||||
}
|
||||
const jwk = parsed as Record<string, unknown>;
|
||||
|
||||
if (jwk.kty !== "EC") {
|
||||
throw new Error(
|
||||
`Unsupported JWK key type: ${
|
||||
String(jwk.kty)
|
||||
}. Only EC keys are supported`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof jwk.d !== "string") {
|
||||
throw new Error(
|
||||
"This JWK is a public key (missing 'd' parameter). The private key JWK is required",
|
||||
);
|
||||
}
|
||||
|
||||
const detectedCurve: KeyCurve = (() => {
|
||||
switch (jwk.crv) {
|
||||
case "secp256k1":
|
||||
return "secp256k1";
|
||||
case "P-256":
|
||||
return "p256";
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported JWK curve: ${
|
||||
String(jwk.crv)
|
||||
}. Expected secp256k1 or P-256`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const privateKeyBytes = fromBase64Url(jwk.d);
|
||||
return importRawBytes(privateKeyBytes, detectedCurve);
|
||||
};
|
||||
|
||||
const importMultikeyOrBase58 = (
|
||||
input: string,
|
||||
curve: KeyCurve,
|
||||
): Promise<PrivateKey> => {
|
||||
try {
|
||||
const match = parsePrivateMultikey(input);
|
||||
return importFromMultikeyMatch(match);
|
||||
} catch {
|
||||
try {
|
||||
parsePublicMultikey(input);
|
||||
throw new Error(
|
||||
"This is a public multikey. The private key multikey is required",
|
||||
);
|
||||
} catch (publicErr) {
|
||||
if (
|
||||
publicErr instanceof Error &&
|
||||
publicErr.message.includes("public multikey")
|
||||
) {
|
||||
throw publicErr;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return importBase58Raw(input, curve);
|
||||
} catch {
|
||||
return importBase58Raw(input.slice(1), curve);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importBase58Raw = (
|
||||
input: string,
|
||||
curve: KeyCurve,
|
||||
): Promise<PrivateKey> => {
|
||||
const bytes = fromBase58Btc(input);
|
||||
if (bytes.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid base58 key: decoded to ${bytes.length} bytes, expected 32`,
|
||||
);
|
||||
}
|
||||
return importRawBytes(bytes, curve);
|
||||
};
|
||||
|
||||
const detectAndImportPrivateKey = (
|
||||
input: string,
|
||||
curve: KeyCurve,
|
||||
): Promise<PrivateKey> => {
|
||||
if (input.startsWith("{")) {
|
||||
return importJwk(input, curve);
|
||||
}
|
||||
|
||||
if (HEX_PRIVATE_KEY_REGEX.test(input)) {
|
||||
return importRawBytes(fromBase16(input.toLowerCase()), curve);
|
||||
}
|
||||
|
||||
if (input.startsWith("z")) {
|
||||
return importMultikeyOrBase58(input, curve);
|
||||
}
|
||||
|
||||
if (BASE58BTC_CHARSET_REGEX.test(input)) {
|
||||
return importBase58Raw(input, curve);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unrecognized key format. Expected hex, base58, multikey, or JWK",
|
||||
);
|
||||
};
|
||||
|
||||
const jsonToB64Url = (obj: unknown): string => {
|
||||
const enc = new TextEncoder();
|
||||
const json = JSON.stringify(obj);
|
||||
@@ -88,42 +225,21 @@ export class PlcOps {
|
||||
|
||||
async getKeyPair(
|
||||
privateKeyString: string,
|
||||
type: "secp256k1" | "p256" = "secp256k1",
|
||||
type: KeyCurve = "secp256k1",
|
||||
): Promise<KeypairInfo> {
|
||||
const HEX_REGEX = /^[0-9a-f]+$/i;
|
||||
const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
|
||||
let keypair: PrivateKey | undefined;
|
||||
|
||||
const trimmed = privateKeyString.trim();
|
||||
|
||||
if (HEX_REGEX.test(trimmed) && trimmed.length === 64) {
|
||||
const privateKeyBytes = fromBase16(trimmed);
|
||||
if (type === "p256") {
|
||||
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
|
||||
} else {
|
||||
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
|
||||
}
|
||||
} else if (MULTIKEY_REGEX.test(trimmed)) {
|
||||
const match = parsePrivateMultikey(trimmed);
|
||||
const privateKeyBytes = match.privateKeyBytes;
|
||||
if (match.type === "p256") {
|
||||
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
|
||||
} else if (match.type === "secp256k1") {
|
||||
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported key type: ${(match as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Private key is required");
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("did:key:")) {
|
||||
throw new Error(
|
||||
"Invalid key format. Expected 64-char hex or multikey format.",
|
||||
"This is a did:key public key identifier. The private key is required",
|
||||
);
|
||||
}
|
||||
|
||||
if (!keypair) {
|
||||
throw new Error("Failed to parse private key");
|
||||
}
|
||||
const keypair = await detectAndImportPrivateKey(trimmed, type);
|
||||
|
||||
return {
|
||||
type: "private_key",
|
||||
|
||||
@@ -323,6 +323,10 @@
|
||||
"disabled": "Disabled",
|
||||
"created": "Invite Code Created",
|
||||
"copy": "Copy",
|
||||
"disable": "Disable",
|
||||
"disableConfirm": "Disable invite code {code}?",
|
||||
"disableSuccess": "Invite code disabled",
|
||||
"disableFailed": "Failed to disable invite code",
|
||||
"createdOn": "Created {date}"
|
||||
},
|
||||
"security": {
|
||||
@@ -504,16 +508,6 @@
|
||||
"status": "Status",
|
||||
"created": "Created",
|
||||
"loadMore": "Load More",
|
||||
"inviteCodes": "Invite Codes",
|
||||
"loadInviteCodes": "Load Invite Codes",
|
||||
"refresh": "Refresh",
|
||||
"noInvites": "No invite codes found",
|
||||
"available": "Available",
|
||||
"uses": "Uses",
|
||||
"disable": "Disable",
|
||||
"disableInviteConfirm": "Disable invite code {code}?",
|
||||
"active": "Active",
|
||||
"exhausted": "Exhausted",
|
||||
"disabled": "Disabled",
|
||||
"userDetails": "User Details",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"verified": "Verified",
|
||||
"unverified": "Unverified",
|
||||
"deactivated": "Deactivated",
|
||||
"inviteDisabled": "Invite code disabled",
|
||||
"invitesEnabled": "User invites enabled",
|
||||
"invitesDisabled": "User invites disabled",
|
||||
"userDeleted": "User account deleted",
|
||||
|
||||
@@ -321,6 +321,10 @@
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"created": "Kutsukoodi luotu",
|
||||
"copy": "Kopioi",
|
||||
"disable": "Poista käytöstä",
|
||||
"disableConfirm": "Poista kutsukoodi {code} käytöstä?",
|
||||
"disableSuccess": "Kutsukoodi poistettu käytöstä",
|
||||
"disableFailed": "Kutsukoodin poistaminen käytöstä epäonnistui",
|
||||
"createdOn": "Luotu {date}",
|
||||
"loadFailed": "Kutsukoodien lataus epäonnistui",
|
||||
"createFailed": "Kutsukoodin luonti epäonnistui"
|
||||
@@ -498,16 +502,6 @@
|
||||
"status": "Tila",
|
||||
"created": "Luotu",
|
||||
"loadMore": "Lataa lisää",
|
||||
"inviteCodes": "Kutsukoodit",
|
||||
"loadInviteCodes": "Lataa kutsukoodit",
|
||||
"refresh": "Päivitä",
|
||||
"noInvites": "Kutsukoodeja ei löytynyt",
|
||||
"available": "Saatavilla",
|
||||
"uses": "Käyttökerrat",
|
||||
"disable": "Poista käytöstä",
|
||||
"disableInviteConfirm": "Poista kutsukoodi {code} käytöstä?",
|
||||
"active": "Aktiivinen",
|
||||
"exhausted": "Käytetty loppuun",
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"userDetails": "Käyttäjän tiedot",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"failedToLoadUsers": "Käyttäjien lataus epäonnistui",
|
||||
"searchToSeeUsers": "Hae nähdäksesi käyttäjät",
|
||||
"search": "Hae",
|
||||
"inviteDisabled": "Kutsukoodi poistettu käytöstä",
|
||||
"invitesEnabled": "Käyttäjäkutsut käytössä",
|
||||
"invitesDisabled": "Käyttäjäkutsut pois käytöstä",
|
||||
"userDeleted": "Käyttäjätili poistettu",
|
||||
|
||||
@@ -321,6 +321,10 @@
|
||||
"disabled": "無効",
|
||||
"created": "招待コードを作成しました",
|
||||
"copy": "コピー",
|
||||
"disable": "無効化",
|
||||
"disableConfirm": "招待コード {code} を無効にしますか?",
|
||||
"disableSuccess": "招待コードを無効にしました",
|
||||
"disableFailed": "招待コードの無効化に失敗しました",
|
||||
"createdOn": "{date} に作成",
|
||||
"loadFailed": "招待コードの読み込みに失敗しました",
|
||||
"createFailed": "招待コードの作成に失敗しました"
|
||||
@@ -498,16 +502,6 @@
|
||||
"status": "ステータス",
|
||||
"created": "作成日時",
|
||||
"loadMore": "さらに読み込む",
|
||||
"inviteCodes": "招待コード",
|
||||
"loadInviteCodes": "招待コードを読み込む",
|
||||
"refresh": "更新",
|
||||
"noInvites": "招待コードが見つかりません",
|
||||
"available": "利用可能",
|
||||
"uses": "使用回数",
|
||||
"disable": "無効化",
|
||||
"disableInviteConfirm": "招待コード {code} を無効にしますか?",
|
||||
"active": "アクティブ",
|
||||
"exhausted": "使用済み",
|
||||
"disabled": "無効",
|
||||
"userDetails": "ユーザー詳細",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"failedToLoadUsers": "ユーザーの読み込みに失敗しました",
|
||||
"searchToSeeUsers": "検索してユーザーを表示",
|
||||
"search": "検索",
|
||||
"inviteDisabled": "招待コードを無効にしました",
|
||||
"invitesEnabled": "ユーザー招待を有効にしました",
|
||||
"invitesDisabled": "ユーザー招待を無効にしました",
|
||||
"userDeleted": "ユーザーアカウントを削除しました",
|
||||
|
||||
@@ -321,6 +321,10 @@
|
||||
"disabled": "비활성화됨",
|
||||
"created": "초대 코드가 생성되었습니다",
|
||||
"copy": "복사",
|
||||
"disable": "비활성화",
|
||||
"disableConfirm": "초대 코드 {code}을(를) 비활성화하시겠습니까?",
|
||||
"disableSuccess": "초대 코드가 비활성화되었습니다",
|
||||
"disableFailed": "초대 코드 비활성화 실패",
|
||||
"createdOn": "{date}에 생성됨",
|
||||
"loadFailed": "초대 코드 로딩 실패",
|
||||
"createFailed": "초대 코드 생성 실패"
|
||||
@@ -498,16 +502,6 @@
|
||||
"status": "상태",
|
||||
"created": "생성일",
|
||||
"loadMore": "더 불러오기",
|
||||
"inviteCodes": "초대 코드",
|
||||
"loadInviteCodes": "초대 코드 불러오기",
|
||||
"refresh": "새로고침",
|
||||
"noInvites": "초대 코드가 없습니다",
|
||||
"available": "사용 가능",
|
||||
"uses": "사용 횟수",
|
||||
"disable": "비활성화",
|
||||
"disableInviteConfirm": "초대 코드 {code}을(를) 비활성화하시겠습니까?",
|
||||
"active": "활성",
|
||||
"exhausted": "소진됨",
|
||||
"disabled": "비활성화됨",
|
||||
"userDetails": "사용자 세부 정보",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"failedToLoadUsers": "사용자 로딩 실패",
|
||||
"searchToSeeUsers": "검색하여 사용자 보기",
|
||||
"search": "검색",
|
||||
"inviteDisabled": "초대 코드가 비활성화되었습니다",
|
||||
"invitesEnabled": "사용자 초대가 활성화되었습니다",
|
||||
"invitesDisabled": "사용자 초대가 비활성화되었습니다",
|
||||
"userDeleted": "사용자 계정이 삭제되었습니다",
|
||||
|
||||
@@ -320,6 +320,10 @@
|
||||
"disabled": "Inaktiverad",
|
||||
"created": "Inbjudningskod skapad",
|
||||
"copy": "Kopiera",
|
||||
"disable": "Inaktivera",
|
||||
"disableConfirm": "Inaktivera inbjudningskod {code}?",
|
||||
"disableSuccess": "Inbjudningskod inaktiverad",
|
||||
"disableFailed": "Kunde inte inaktivera inbjudningskod",
|
||||
"createdOn": "Skapad {date}",
|
||||
"spent": "Förbrukad",
|
||||
"loadFailed": "Kunde inte ladda inbjudningskoder",
|
||||
@@ -498,16 +502,6 @@
|
||||
"status": "Status",
|
||||
"created": "Skapad",
|
||||
"loadMore": "Ladda fler",
|
||||
"inviteCodes": "Inbjudningskoder",
|
||||
"loadInviteCodes": "Ladda inbjudningskoder",
|
||||
"refresh": "Uppdatera",
|
||||
"noInvites": "Inga inbjudningskoder hittades",
|
||||
"available": "Tillgänglig",
|
||||
"uses": "Användningar",
|
||||
"disable": "Inaktivera",
|
||||
"disableInviteConfirm": "Inaktivera inbjudningskod {code}?",
|
||||
"active": "Aktiv",
|
||||
"exhausted": "Förbrukad",
|
||||
"disabled": "Inaktiverad",
|
||||
"userDetails": "Användardetaljer",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"failedToLoadUsers": "Kunde inte ladda användare",
|
||||
"searchToSeeUsers": "Sök för att visa användare",
|
||||
"search": "Sök",
|
||||
"inviteDisabled": "Inbjudningskod inaktiverad",
|
||||
"invitesEnabled": "Användarinbjudningar aktiverade",
|
||||
"invitesDisabled": "Användarinbjudningar inaktiverade",
|
||||
"userDeleted": "Användarkonto raderat",
|
||||
|
||||
@@ -320,6 +320,10 @@
|
||||
"disabled": "已禁用",
|
||||
"created": "邀请码已创建",
|
||||
"copy": "复制",
|
||||
"disable": "禁用",
|
||||
"disableConfirm": "禁用邀请码 {code}?",
|
||||
"disableSuccess": "邀请码已禁用",
|
||||
"disableFailed": "禁用邀请码失败",
|
||||
"createdOn": "创建于 {date}",
|
||||
"spent": "已使用",
|
||||
"loadFailed": "加载邀请码失败",
|
||||
@@ -500,16 +504,6 @@
|
||||
"status": "状态",
|
||||
"created": "创建时间",
|
||||
"loadMore": "加载更多",
|
||||
"inviteCodes": "邀请码",
|
||||
"loadInviteCodes": "加载邀请码",
|
||||
"refresh": "刷新",
|
||||
"noInvites": "暂无邀请码",
|
||||
"available": "可用",
|
||||
"uses": "使用次数",
|
||||
"disable": "禁用",
|
||||
"disableInviteConfirm": "禁用邀请码 {code}?",
|
||||
"active": "活跃",
|
||||
"exhausted": "已用完",
|
||||
"disabled": "已禁用",
|
||||
"userDetails": "用户详情",
|
||||
"did": "DID",
|
||||
@@ -526,7 +520,6 @@
|
||||
"failedToLoadUsers": "加载用户失败",
|
||||
"searchToSeeUsers": "搜索以查看用户",
|
||||
"search": "搜索",
|
||||
"inviteDisabled": "邀请码已禁用",
|
||||
"invitesEnabled": "用户邀请已启用",
|
||||
"invitesDisabled": "用户邀请已禁用",
|
||||
"userDeleted": "用户账户已删除",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlcOps, plcOps } from "../../lib/migration/plc-ops.ts";
|
||||
import {
|
||||
P256PrivateKeyExportable,
|
||||
Secp256k1PrivateKeyExportable,
|
||||
} from "@atcute/crypto";
|
||||
import { fromBase58Btc, toBase58Btc } from "@atcute/multibase";
|
||||
|
||||
describe("migration/plc-ops", () => {
|
||||
beforeEach(() => {
|
||||
@@ -89,15 +94,222 @@ describe("migration/plc-ops", () => {
|
||||
|
||||
it("throws for invalid key format", async () => {
|
||||
await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow(
|
||||
"Invalid key format",
|
||||
"Unrecognized key format",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for hex key with wrong length", async () => {
|
||||
await expect(plcOps.getKeyPair("abc123")).rejects.toThrow(
|
||||
"Invalid key format",
|
||||
await expect(plcOps.getKeyPair("abc123")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - multikey round-trip", () => {
|
||||
it("round-trips from createNewSecp256k1Keypair", async () => {
|
||||
const { privateKey, publicKey } = await plcOps
|
||||
.createNewSecp256k1Keypair();
|
||||
|
||||
const result = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
expect(result.didPublicKey).toBe(publicKey);
|
||||
});
|
||||
|
||||
it("produces correct multikey structure (z prefix, codec bytes)", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
|
||||
expect(privateKey.startsWith("z")).toBe(true);
|
||||
const decoded = fromBase58Btc(privateKey.slice(1));
|
||||
expect(decoded[0]).toBe(0x81);
|
||||
expect(decoded[1]).toBe(0x26);
|
||||
expect(decoded.length).toBe(34);
|
||||
});
|
||||
|
||||
it("multikey import matches hex import of same raw bytes", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const multikey = await keypair.exportPrivateKey("multikey");
|
||||
const rawHex = await keypair.exportPrivateKey("rawHex");
|
||||
|
||||
const fromMultikey = await plcOps.getKeyPair(multikey);
|
||||
const fromHex = await plcOps.getKeyPair(rawHex);
|
||||
|
||||
expect(fromMultikey.didPublicKey).toBe(fromHex.didPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - hex format", () => {
|
||||
it("accepts uppercase hex", async () => {
|
||||
const result = await plcOps.getKeyPair("A".repeat(64));
|
||||
|
||||
expect(result.type).toBe("private_key");
|
||||
expect(result.didPublicKey.startsWith("did:key:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - JWK format", () => {
|
||||
it("imports secp256k1 JWK with d parameter", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const jwk = await keypair.exportPrivateKey("jwk");
|
||||
const expectedDid = await keypair.exportPublicKey("did");
|
||||
|
||||
const result = await plcOps.getKeyPair(JSON.stringify(jwk));
|
||||
|
||||
expect(result.didPublicKey).toBe(expectedDid);
|
||||
});
|
||||
|
||||
it("imports P-256 JWK with d parameter", async () => {
|
||||
const keypair = await P256PrivateKeyExportable.createKeypair();
|
||||
const jwk = await keypair.exportPrivateKey("jwk");
|
||||
const expectedDid = await keypair.exportPublicKey("did");
|
||||
|
||||
const result = await plcOps.getKeyPair(JSON.stringify(jwk));
|
||||
|
||||
expect(result.didPublicKey).toBe(expectedDid);
|
||||
});
|
||||
|
||||
it("rejects JWK without d (public key)", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const jwk = await keypair.exportPublicKey("jwk");
|
||||
|
||||
await expect(
|
||||
plcOps.getKeyPair(JSON.stringify(jwk)),
|
||||
).rejects.toThrow("public key");
|
||||
});
|
||||
|
||||
it("rejects unsupported kty", async () => {
|
||||
const jwk = { kty: "RSA", n: "abc", e: "AQAB" };
|
||||
|
||||
await expect(plcOps.getKeyPair(JSON.stringify(jwk))).rejects.toThrow(
|
||||
"Unsupported JWK key type",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported crv", async () => {
|
||||
const jwk = { kty: "EC", crv: "P-384", d: "AAAA", x: "BBBB", y: "CCCC" };
|
||||
|
||||
await expect(plcOps.getKeyPair(JSON.stringify(jwk))).rejects.toThrow(
|
||||
"Unsupported JWK curve",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON", async () => {
|
||||
await expect(plcOps.getKeyPair("{not valid json")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("produces same public key as hex import of same raw bytes", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const jwk = await keypair.exportPrivateKey("jwk");
|
||||
const rawHex = await keypair.exportPrivateKey("rawHex");
|
||||
|
||||
const fromJwk = await plcOps.getKeyPair(JSON.stringify(jwk));
|
||||
const fromHex = await plcOps.getKeyPair(rawHex);
|
||||
|
||||
expect(fromJwk.didPublicKey).toBe(fromHex.didPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - plain base58 format", () => {
|
||||
it("imports base58-encoded 32-byte raw key", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const rawBytes = await keypair.exportPrivateKey("raw");
|
||||
const base58 = toBase58Btc(rawBytes);
|
||||
const expectedDid = await keypair.exportPublicKey("did");
|
||||
|
||||
const result = await plcOps.getKeyPair(base58);
|
||||
|
||||
expect(result.didPublicKey).toBe(expectedDid);
|
||||
});
|
||||
|
||||
it("produces same public key as hex import of same raw bytes", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const rawBytes = await keypair.exportPrivateKey("raw");
|
||||
const rawHex = await keypair.exportPrivateKey("rawHex");
|
||||
const base58 = toBase58Btc(rawBytes);
|
||||
|
||||
const fromBase58 = await plcOps.getKeyPair(base58);
|
||||
const fromHex = await plcOps.getKeyPair(rawHex);
|
||||
|
||||
expect(fromBase58.didPublicKey).toBe(fromHex.didPublicKey);
|
||||
});
|
||||
|
||||
it("rejects wrong decoded length", async () => {
|
||||
const shortBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(shortBytes);
|
||||
const base58Short = toBase58Btc(shortBytes);
|
||||
|
||||
await expect(plcOps.getKeyPair(base58Short)).rejects.toThrow(
|
||||
"expected 32",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - cross-format consistency", () => {
|
||||
it("hex, multikey, and JWK all produce identical did:key", async () => {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const rawHex = await keypair.exportPrivateKey("rawHex");
|
||||
const multikey = await keypair.exportPrivateKey("multikey");
|
||||
const jwk = await keypair.exportPrivateKey("jwk");
|
||||
|
||||
const [fromHex, fromMultikey, fromJwk] = await Promise.all([
|
||||
plcOps.getKeyPair(rawHex),
|
||||
plcOps.getKeyPair(multikey),
|
||||
plcOps.getKeyPair(JSON.stringify(jwk)),
|
||||
]);
|
||||
|
||||
expect(fromHex.didPublicKey).toBe(fromMultikey.didPublicKey);
|
||||
expect(fromHex.didPublicKey).toBe(fromJwk.didPublicKey);
|
||||
});
|
||||
|
||||
it("hex, multikey, JWK, and base58 all match for P-256", async () => {
|
||||
const keypair = await P256PrivateKeyExportable.createKeypair();
|
||||
const rawHex = await keypair.exportPrivateKey("rawHex");
|
||||
const multikey = await keypair.exportPrivateKey("multikey");
|
||||
const jwk = await keypair.exportPrivateKey("jwk");
|
||||
const rawBytes = await keypair.exportPrivateKey("raw");
|
||||
const base58 = toBase58Btc(rawBytes);
|
||||
|
||||
const [fromHex, fromMultikey, fromJwk, fromBase58] = await Promise.all([
|
||||
plcOps.getKeyPair(rawHex, "p256"),
|
||||
plcOps.getKeyPair(multikey),
|
||||
plcOps.getKeyPair(JSON.stringify(jwk)),
|
||||
plcOps.getKeyPair(base58, "p256"),
|
||||
]);
|
||||
|
||||
expect(fromHex.didPublicKey).toBe(fromMultikey.didPublicKey);
|
||||
expect(fromHex.didPublicKey).toBe(fromJwk.didPublicKey);
|
||||
expect(fromHex.didPublicKey).toBe(fromBase58.didPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair - error cases", () => {
|
||||
it("rejects empty string", async () => {
|
||||
await expect(plcOps.getKeyPair("")).rejects.toThrow(
|
||||
"Private key is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only", async () => {
|
||||
await expect(plcOps.getKeyPair(" ")).rejects.toThrow(
|
||||
"Private key is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects did:key: prefix with helpful error", async () => {
|
||||
await expect(
|
||||
plcOps.getKeyPair(
|
||||
"did:key:zQ3shunBKoL5VRgSEX7RQGQEG3TTo6MPVWvT7tcVjjwZCWMEE",
|
||||
),
|
||||
).rejects.toThrow("public key");
|
||||
});
|
||||
|
||||
it("rejects unrecognized garbage", async () => {
|
||||
await expect(plcOps.getKeyPair("!!!invalid!!!")).rejects.toThrow(
|
||||
"Unrecognized key format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects hex with non-hex chars in 64-char string", async () => {
|
||||
const almostHex = "g".repeat(64);
|
||||
await expect(plcOps.getKeyPair(almostHex)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushPlcOperation", () => {
|
||||
|
||||
Reference in New Issue
Block a user