atproto pds in zig pds.zat.dev
pds atproto
Something went wrong. Try again.
Zig
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535const std = @import("std");const auth = @import("../auth/tokens.zig");const config = @import("../core/config.zig");const log = @import("../core/log.zig");const mail = @import("../core/mail.zig");const http_api = @import("../http/api.zig");const email_tokens = @import("../internal/email_tokens.zig");const handles = @import("../internal/handles.zig");const scopes = @import("../internal/scopes.zig");const plc = @import("plc.zig");const store = @import("../storage/store.zig");const zat = @import("zat");
const http = std.http;
pub fn getRecommendedDidCredentials(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = requireAccount(request, allocator) catch return; const account = auth_ctx.account; var keypair = try store.signingKeypair(account.did); const signing_did_key = try keypair.did(allocator); const rotation_keys_json = if (std.mem.startsWith(u8, account.did, "did:web:")) "[]" else keys: { var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), }; const rotation_keys = try plc.rotationDidKeys(allocator, &rotation_keypair); break :keys try jsonStringArray(allocator, rotation_keys); }; const body = try std.fmt.allocPrint( allocator, "{{\"alsoKnownAs\":[\"at://{s}\"],\"verificationMethods\":{{\"atproto\":{f}}},\"rotationKeys\":{s},\"services\":{{\"atproto_pds\":{{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":{f}}}}}}}", .{ account.handle, std.json.fmt(signing_did_key, .{}), rotation_keys_json, std.json.fmt(config.publicUrl(), .{}), }, ); return http_api.json(request, .ok, body);}
pub fn requestPlcOperationSignature(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const auth_ctx = requireAccount(request, allocator) catch return; try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); const account = auth_ctx.account; const info = store.getEmailInfo(allocator, account.did) orelse { return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); }; var code_buf: [11]u8 = undefined; const code = email_tokens.makeCode(&code_buf); try store.setAuthCode(account.did, code, store.nowMs() + (10 * 60 * 1000)); try mail.sendCode(store.currentIo(), allocator, info.email, account.handle, "PLC operation", "PLC operation", code); return http_api.json(request, .ok, "{}");}
pub fn signPlcOperation(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = requireAccount(request, allocator) catch return; try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); const account = auth_ctx.account; const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const input = switch (parsed.value) { .object => |object| object, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), }; const token = switch (input.get("token") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email confirmation token required to sign PLC operations")) { .string => |string| string, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "email confirmation token required to sign PLC operations"), }; switch (store.validateAuthCode(account.did, token, store.nowMs())) { .valid => {}, .expired => return http_api.xrpcError(request, .bad_request, "ExpiredToken", "token expired"), .invalid => return http_api.xrpcError(request, .bad_request, "InvalidToken", "invalid token"), }
const last_op = fetchLastPlcOp(allocator, account.did) catch { return http_api.xrpcError(request, .bad_gateway, "PlcFetchFailed", "failed to fetch current PLC operation"); }; if (jsonObjectStringEquals(last_op, "type", "plc_tombstone")) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID is tombstoned"); }
const prev = try cidForJsonValue(allocator, last_op); const unsigned_json = try writeUnsignedPlcOperationJson(allocator, last_op, input, prev); const unsigned_parsed = try std.json.parseFromSlice(std.json.Value, allocator, unsigned_json, .{}); const unsigned_cbor = try jsonToCbor(allocator, unsigned_parsed.value); const encoded = try zat.cbor.encodeAlloc(allocator, unsigned_cbor); var signing_keypair = try plcOperationSigningKeypair(request, allocator, account, last_op); const sig = try signing_keypair.sign(encoded); const sig_text = try zat.jwt.base64UrlEncode(allocator, &sig.bytes); try store.clearAuthCode(account.did);
var response_writer: std.Io.Writer.Allocating = .init(allocator); defer response_writer.deinit(); try response_writer.writer.writeAll("{\"operation\":"); try response_writer.writer.writeAll(unsigned_json[0 .. unsigned_json.len - 1]); try response_writer.writer.print(",\"sig\":{f}}}", .{std.json.fmt(sig_text, .{})}); const response = try response_writer.toOwnedSlice(); return http_api.json(request, .ok, response);}
pub fn submitPlcOperation(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = requireAccount(request, allocator) catch return; try requireIdentityScope(request, auth_ctx.oauth_scope, .wildcard); const account = auth_ctx.account; const body = try http_api.readBodyAlloc(request, allocator, 1024 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const operation = switch (parsed.value) { .object => |object| object.get("operation") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing operation"); }, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Expected object"), }; try validatePlcOperation(request, allocator, account, operation);
const op_json = try stringifyJson(allocator, operation); const escaped_did = try percentEncodeDid(allocator, account.did); var url_buf: [320]u8 = undefined; const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}", .{ config.plcDirectory(), escaped_did }); var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = transport.fetch(.{ .url = url, .method = .POST, .payload = op_json, .content_type = "application/json", .max_response_size = 64 * 1024, }) catch { return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "failed to submit PLC operation"); }; if (@backingInt(result.status) < 200 or @backingInt(result.status) >= 300) { return http_api.xrpcError(request, .bad_gateway, "PlcSubmissionFailed", "PLC directory rejected operation"); } return http_api.json(request, .ok, "{}");}
pub fn resolveHandle(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
var handle_buf: [256]u8 = undefined; const handle_param = http_api.queryParam(request.url.raw, "handle", &handle_buf) orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); }; const handle = handles.normalize(allocator, handle_param) catch { return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle format"); };
const did = if (try store.findActiveAccount(allocator, handle)) |account| account.did else if (handles.isHostedAuthority(handle, config.handleDomains())) return http_api.xrpcError(request, .not_found, "HandleNotFound", "Unable to resolve handle") else did: { const parsed_handle = zat.Handle.parse(handle) orelse unreachable; var resolver = zat.HandleResolver.init(store.currentIo(), allocator); defer resolver.deinit(); break :did resolver.resolve(parsed_handle) catch { return http_api.xrpcError(request, .not_found, "HandleNotFound", "Unable to resolve handle"); }; };
const body = try std.fmt.allocPrint(allocator, "{{\"did\":{f}}}", .{std.json.fmt(did, .{})}); return http_api.json(request, .ok, body);}
pub fn updateHandle(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator();
const auth_ctx = requireAccount(request, allocator) catch return; try requireIdentityScope(request, auth_ctx.oauth_scope, .handle); const account = auth_ctx.account; switch (store.accountStatus(account.did) catch .deleted) { .active, .deactivated => {}, .takendown => return http_api.xrpcError(request, .forbidden, "AccountTakedown", "Account has been taken down"), .suspended => return http_api.xrpcError(request, .forbidden, "AccountSuspended", "Account is suspended"), .deleted => return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"), } const now = store.nowMs(); store.consumeRateLimit(account.did, "identity.updateHandle.5m", now, 5 * 60 * 1000, 10) catch |err| switch (err) { error.RateLimitExceeded => return http_api.xrpcError(request, .too_many_requests, "RateLimitExceeded", "Too many handle updates"), else => return err, }; store.consumeRateLimit(account.did, "identity.updateHandle.day", now, 24 * 60 * 60 * 1000, 50) catch |err| switch (err) { error.RateLimitExceeded => return http_api.xrpcError(request, .too_many_requests, "RateLimitExceeded", "Too many handle updates"), else => return err, };
const body = try http_api.readBodyAlloc(request, allocator, 16 * 1024); const parsed = try http_api.parseJsonBody(request, allocator, body); const raw_handle = zat.json.getString(parsed.value, "handle") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing handle"); }; const handle = handles.normalize(allocator, raw_handle) catch { return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid handle"); };
if (handles.isHosted(handle, config.handleDomains())) { handles.validateHosted(handle, config.handleDomains()) catch |err| switch (err) { error.InvalidHostedHandle => return http_api.xrpcError(request, .bad_request, "InvalidHandle", "Invalid hosted handle"), else => return http_api.xrpcError(request, .bad_request, "UnsupportedDomain", "Unsupported handle domain"), }; }
if (std.ascii.eqlIgnoreCase(handle, account.handle)) { store.sequenceIdentityEvent(allocator, account.did, handle) catch |err| { log.err("failed to resequence identity event did={s} handle={s} error={s}\n", .{ account.did, handle, @errorName(err) }); }; return http_api.json(request, .ok, "{}"); }
if (!handles.isHosted(handle, config.handleDomains())) { const parsed_handle = zat.Handle.parse(handle) orelse unreachable; var resolver = zat.HandleResolver.init(store.currentIo(), allocator); defer resolver.deinit(); const resolved_did = resolver.resolve(parsed_handle) catch { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); }; if (!std.mem.eql(u8, resolved_did, account.did)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "External handle did not resolve to account DID"); } if (std.mem.startsWith(u8, account.did, "did:web:")) { var did_resolver = zat.DidResolver.init(store.currentIo(), allocator); defer did_resolver.deinit(); var did_doc = did_resolver.resolve(zat.Did.parse(account.did).?) catch { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID did not resolve"); }; defer did_doc.deinit(); const did_handle = did_doc.handle() orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID does not declare the requested handle"); }; if (!std.ascii.eqlIgnoreCase(did_handle, handle)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account DID does not declare the requested handle"); } } }
if (try store.findAccount(allocator, handle)) |existing| { if (!std.mem.eql(u8, existing.did, account.did)) { return http_api.xrpcError(request, .bad_request, "HandleNotAvailable", "Handle is already in use"); } }
if (std.mem.startsWith(u8, account.did, "did:plc:")) { const last_op = plc.fetchLastOperation(allocator, account.did) catch { return http_api.xrpcError(request, .bad_gateway, "PlcUpdateFailed", "Failed to fetch current PLC operation"); }; if (jsonObjectStringEquals(last_op, "type", "plc_tombstone")) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "DID is tombstoned"); }
var rotation_keypair = plc.configuredRotationKeypair() catch { return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); }; const rotation_did_key = try rotation_keypair.did(allocator); if (!jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "PDS rotation key is not authorized by current PLC operation"); }
const prev = try plc.cidForJsonValue(allocator, last_op); const unsigned = try plc.updateHandleOperationJson(allocator, last_op, handle, prev); const operation = try plc.signOperationJson(allocator, unsigned, &rotation_keypair); plc.submitOperation(allocator, account.did, operation) catch { return http_api.xrpcError(request, .bad_gateway, "PlcUpdateFailed", "Failed to update PLC handle"); }; }
store.updateAccountHandle(account.did, handle) catch |err| switch (err) { error.HandleNotAvailable => return http_api.xrpcError(request, .bad_request, "HandleNotAvailable", "Handle is already in use"), error.AccountNotFound => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Account not found"), else => return err, }; store.sequenceIdentityEvent(allocator, account.did, handle) catch |err| { log.err("failed to sequence identity event did={s} handle={s} error={s}\n", .{ account.did, handle, @errorName(err) }); }; return http_api.json(request, .ok, "{}");}
fn requireAccount(request: *http_api.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { return http_api.requireBearerAccess(request, allocator) catch |err| { try http_api.bearerErrorResponse(request, err); return error.HandledResponse; };}
fn requireIdentityScope(request: *http_api.Request, maybe_scope: ?[]const u8, attr: scopes.IdentityAttr) !void { if (scopes.identityAllows(maybe_scope, attr)) return; try http_api.xrpcError(request, .forbidden, "InsufficientScope", "Insufficient scope"); return error.HandledResponse;}
fn plcOperationSigningKeypair( request: *http_api.Request, allocator: std.mem.Allocator, _: auth.Account, last_op: std.json.Value,) !zat.Keypair { var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { error.MissingPlcRotationKey => { try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"); return error.HandledResponse; }, error.InvalidPlcRotationKey => { try http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"); return error.HandledResponse; }, }; const rotation_did_key = try rotation_keypair.did(allocator); if (jsonArrayContainsString(zat.json.getPath(last_op, "rotationKeys"), rotation_did_key)) { return rotation_keypair; } try http_api.xrpcError(request, .bad_request, "InvalidRequest", "PDS rotation key is not authorized by current PLC rotation keys"); return error.HandledResponse;}
fn validatePlcOperation( request: *http_api.Request, allocator: std.mem.Allocator, account: auth.Account, operation: std.json.Value,) !void { var keypair = try store.signingKeypair(account.did); const did_key = try keypair.did(allocator); const rotation_did_key = if (std.mem.startsWith(u8, account.did, "did:plc:")) key: { var rotation_keypair = plc.configuredRotationKeypair() catch |err| switch (err) { error.MissingPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is not configured"), error.InvalidPlcRotationKey => return http_api.xrpcError(request, .internal_server_error, "InternalServerError", "PLC rotation key is invalid"), }; break :key try rotation_keypair.did(allocator); } else did_key; const object = switch (operation) { .object => |object| object, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid operation"), }; if (!jsonArrayContainsString(object.get("rotationKeys"), rotation_did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys do not include server rotation key"); } if (jsonArrayContainsString(object.get("rotationKeys"), did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Rotation keys must not include the account signing key"); } const verification_methods = object.get("verificationMethods") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing verificationMethods"); }; if (!jsonObjectStringEquals(verification_methods, "atproto", did_key)) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect signing key"); } if (!jsonArrayFirstStringEquals(object.get("alsoKnownAs"), try std.fmt.allocPrint(allocator, "at://{s}", .{account.handle}))) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect handle in alsoKnownAs"); } const services = object.get("services") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing services"); }; const atproto_pds = switch (services) { .object => |services_object| services_object.get("atproto_pds") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing atproto_pds service"); }, else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid services"), }; if (!jsonObjectStringEquals(atproto_pds, "type", "AtprotoPersonalDataServer") or !jsonObjectStringEquals(atproto_pds, "endpoint", config.publicUrl())) { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Incorrect atproto_pds service"); }}
fn jsonStringArray(allocator: std.mem.Allocator, items: []const []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeByte('['); for (items, 0..) |item, idx| { if (idx != 0) try out.writer.writeByte(','); try out.writer.print("{f}", .{std.json.fmt(item, .{})}); } try out.writer.writeByte(']'); return out.toOwnedSlice();}
fn stringifyJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.print("{f}", .{std.json.fmt(value, .{})}); return out.toOwnedSlice();}
fn fetchLastPlcOp(allocator: std.mem.Allocator, did: []const u8) !std.json.Value { const escaped_did = try percentEncodeDid(allocator, did); var url_buf: [340]u8 = undefined; const url = try std.fmt.bufPrint(&url_buf, "{s}/{s}/log/last", .{ config.plcDirectory(), escaped_did }); var transport = zat.HttpTransport.init(store.currentIo(), allocator); defer transport.deinit(); const result = try transport.fetch(.{ .url = url, .method = .GET, .accept = "application/json", .max_response_size = 1024 * 1024, }); if (@backingInt(result.status) < 200 or @backingInt(result.status) >= 300) return error.PlcFetchFailed; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.body, .{}); return parsed.value;}
fn writeUnsignedPlcOperationJson( allocator: std.mem.Allocator, last_op: std.json.Value, input: std.json.ObjectMap, prev: []const u8,) ![]const u8 { const last = switch (last_op) { .object => |object| object, else => return error.InvalidPlcOperation, }; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"type\":\"plc_operation\""); try writePlcField(allocator, &out.writer, "verificationMethods", input, last); try writePlcField(allocator, &out.writer, "rotationKeys", input, last); try writePlcField(allocator, &out.writer, "alsoKnownAs", input, last); try writePlcField(allocator, &out.writer, "services", input, last); try out.writer.print(",\"prev\":{f}}}", .{std.json.fmt(prev, .{})}); return out.toOwnedSlice();}
fn writePlcField( allocator: std.mem.Allocator, writer: anytype, name: []const u8, input: std.json.ObjectMap, last: std.json.ObjectMap,) !void { const value = input.get(name) orelse last.get(name) orelse return error.InvalidPlcOperation; try writer.print(",{f}:{s}", .{ std.json.fmt(name, .{}), try stringifyJson(allocator, value) });}
fn cidForJsonValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { const cbor_value = try jsonToCbor(allocator, value); const encoded = try zat.cbor.encodeAlloc(allocator, cbor_value); const cid = try zat.cbor.Cid.forDagCbor(allocator, encoded); return zat.multibase.base32lower.encode(allocator, cid.raw);}
fn jsonToCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { return switch (value) { .null => .null, .bool => |boolean| .{ .boolean = boolean }, .integer => |integer| if (integer >= 0) .{ .unsigned = @intCast(integer) } else .{ .negative = integer }, .float => return error.InvalidDagCbor, .number_string => return error.InvalidDagCbor, .string => |string| .{ .text = string }, .array => |array| blk: { const items = try allocator.alloc(zat.cbor.Value, array.items.len); for (array.items, 0..) |item, idx| items[idx] = try jsonToCbor(allocator, item); break :blk .{ .array = items }; }, .object => |object| blk: { const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); var it = object.iterator(); var idx: usize = 0; while (it.next()) |entry| : (idx += 1) { entries[idx] = .{ .key = entry.key_ptr.*, .value = try jsonToCbor(allocator, entry.value_ptr.*), }; } break :blk .{ .map = entries }; }, };}
fn percentEncodeDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); for (did) |c| switch (c) { ':' => try out.writer.writeAll("%3A"), else => try out.writer.writeByte(c), }; return out.toOwnedSlice();}
fn jsonObjectStringEquals(value: std.json.Value, key: []const u8, expected: []const u8) bool { const actual = switch (value) { .object => |object| switch (object.get(key) orelse return false) { .string => |string| string, else => return false, }, else => return false, }; return std.mem.eql(u8, actual, expected);}
fn jsonArrayContainsString(value: ?std.json.Value, expected: []const u8) bool { const items = switch (value orelse return false) { .array => |array| array.items, else => return false, }; for (items) |item| switch (item) { .string => |string| if (std.mem.eql(u8, string, expected)) return true, else => {}, }; return false;}
fn jsonArrayFirstStringEquals(value: ?std.json.Value, expected: []const u8) bool { const items = switch (value orelse return false) { .array => |array| array.items, else => return false, }; if (items.len == 0) return false; return switch (items[0]) { .string => |string| std.mem.eql(u8, string, expected), else => false, };}