2022-12-26 16:05:26 +00:00
|
|
|
const std = @import("std");
|
|
|
|
const util = @import("util");
|
2023-01-03 01:21:08 +00:00
|
|
|
const pkg = @import("../lib.zig");
|
|
|
|
const services = @import("../services.zig");
|
2023-01-04 19:03:23 +00:00
|
|
|
const invites = @import("./invites.zig");
|
2022-12-26 16:05:26 +00:00
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
const Allocator = std.mem.Allocator;
|
2022-12-26 16:05:26 +00:00
|
|
|
const Uuid = util.Uuid;
|
|
|
|
const DateTime = util.DateTime;
|
2023-01-03 01:21:08 +00:00
|
|
|
const ApiContext = pkg.ApiContext;
|
2023-01-04 19:03:23 +00:00
|
|
|
const Token = pkg.tokens.Token;
|
|
|
|
const RegistrationOptions = pkg.auth.RegistrationOptions;
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
const Invite = services.invites.Invite;
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
pub fn register(
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
ctx: ApiContext,
|
|
|
|
svcs: anytype,
|
|
|
|
opt: RegistrationOptions,
|
2023-01-04 19:03:23 +00:00
|
|
|
) !Uuid {
|
2023-01-03 01:17:42 +00:00
|
|
|
const tx = try svcs.beginTx();
|
|
|
|
errdefer tx.rollbackTx();
|
|
|
|
|
|
|
|
const maybe_invite = if (opt.invite_code) |code|
|
2023-01-08 23:35:58 +00:00
|
|
|
tx.getInviteByCode(alloc, code, ctx.community.id) catch |err| switch (err) {
|
|
|
|
error.NotFound => return error.InvalidInvite,
|
|
|
|
else => |e| return e,
|
|
|
|
}
|
2023-01-03 01:17:42 +00:00
|
|
|
else
|
|
|
|
null;
|
|
|
|
defer if (maybe_invite) |inv| util.deepFree(alloc, inv);
|
|
|
|
|
|
|
|
if (maybe_invite) |invite| {
|
|
|
|
if (!Uuid.eql(invite.community_id, ctx.community.id)) return error.WrongCommunity;
|
2023-01-04 19:03:23 +00:00
|
|
|
if (!invites.isValid(invite)) return error.InvalidInvite;
|
2023-01-03 01:17:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const invite_kind = if (maybe_invite) |inv| inv.kind else .user;
|
|
|
|
|
|
|
|
if (ctx.community.kind == .admin) @panic("Unimplmented");
|
|
|
|
|
2023-01-04 19:03:23 +00:00
|
|
|
const account_id = try createLocalAccount(
|
2023-01-03 01:17:42 +00:00
|
|
|
alloc,
|
|
|
|
tx,
|
|
|
|
.{
|
2023-01-08 23:35:58 +00:00
|
|
|
.username = opt.username,
|
|
|
|
.password = opt.password,
|
|
|
|
.community_id = ctx.community.id,
|
2023-01-03 01:17:42 +00:00
|
|
|
.invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null,
|
|
|
|
.email = opt.email,
|
|
|
|
},
|
|
|
|
);
|
2022-12-26 16:05:26 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
switch (invite_kind) {
|
|
|
|
.user => {},
|
|
|
|
.system => @panic("System user invites unimplemented"),
|
|
|
|
.community_owner => {
|
2023-01-04 19:03:23 +00:00
|
|
|
try tx.transferCommunityOwnership(ctx.community.id, account_id);
|
2023-01-03 01:17:42 +00:00
|
|
|
},
|
|
|
|
}
|
2022-12-26 16:05:26 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
try tx.commitTx();
|
2023-01-04 19:03:23 +00:00
|
|
|
return account_id;
|
2023-01-03 01:17:42 +00:00
|
|
|
}
|
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
pub const AccountCreateArgs = struct {
|
2023-01-03 01:17:42 +00:00
|
|
|
username: []const u8,
|
|
|
|
password: []const u8,
|
|
|
|
community_id: Uuid,
|
2023-01-08 23:35:58 +00:00
|
|
|
invite_id: ?Uuid = null,
|
|
|
|
email: ?[]const u8 = null,
|
|
|
|
role: services.accounts.Role = .user,
|
|
|
|
};
|
|
|
|
|
|
|
|
pub fn createLocalAccount(
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
svcs: anytype,
|
|
|
|
args: AccountCreateArgs,
|
2023-01-03 01:17:42 +00:00
|
|
|
) !Uuid {
|
|
|
|
const tx = try svcs.beginTx();
|
|
|
|
errdefer tx.rollbackTx();
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
const hash = try hashPassword(args.password, alloc);
|
2023-01-03 01:17:42 +00:00
|
|
|
defer alloc.free(hash);
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
const id = try tx.createActor(alloc, args.username, args.community_id, false);
|
|
|
|
try tx.createAccount(alloc, .{
|
|
|
|
.for_actor = id,
|
|
|
|
.password_hash = hash,
|
|
|
|
.invite_id = args.invite_id,
|
|
|
|
.email = args.email,
|
|
|
|
.role = args.role,
|
|
|
|
});
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
try tx.commitTx();
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
return id;
|
|
|
|
}
|
2023-01-02 01:18:05 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
pub fn verifyToken(alloc: std.mem.Allocator, ctx: ApiContext, svcs: anytype, token: []const u8) !Token.Info {
|
|
|
|
const hash = try hashToken(token, alloc);
|
|
|
|
defer alloc.free(hash);
|
2023-01-02 01:32:17 +00:00
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
const info = try svcs.getTokenByHash(alloc, hash, ctx.community.id);
|
|
|
|
defer util.deepFree(alloc, info);
|
2023-01-02 07:34:30 +00:00
|
|
|
|
2023-01-04 19:03:23 +00:00
|
|
|
return .{ .account_id = info.account_id, .issued_at = info.issued_at };
|
2022-12-26 16:05:26 +00:00
|
|
|
}
|
|
|
|
|
2023-01-03 01:17:42 +00:00
|
|
|
pub fn login(
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
ctx: ApiContext,
|
|
|
|
svcs: anytype,
|
|
|
|
username: []const u8,
|
|
|
|
password: []const u8,
|
|
|
|
) !Token {
|
2023-01-02 20:38:42 +00:00
|
|
|
const community_id = ctx.community.id;
|
|
|
|
const credentials = try svcs.getCredentialsByUsername(
|
|
|
|
alloc,
|
|
|
|
username,
|
|
|
|
community_id,
|
|
|
|
);
|
|
|
|
defer util.deepFree(alloc, credentials);
|
|
|
|
|
|
|
|
try verifyPassword(credentials.password_hash, password, alloc);
|
|
|
|
|
|
|
|
const token = try generateToken(alloc);
|
|
|
|
errdefer util.deepFree(alloc, token);
|
|
|
|
const token_hash = hashToken(token, alloc) catch |err| switch (err) {
|
|
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
|
|
else => unreachable,
|
|
|
|
};
|
|
|
|
defer util.deepFree(alloc, token_hash);
|
|
|
|
|
|
|
|
const tx = try svcs.beginTx();
|
|
|
|
errdefer tx.rollbackTx();
|
|
|
|
|
|
|
|
// ensure that the password has not changed in the meantime
|
|
|
|
{
|
|
|
|
const updated_info = try tx.getCredentialsByUsername(
|
|
|
|
alloc,
|
|
|
|
username,
|
|
|
|
community_id,
|
|
|
|
);
|
|
|
|
defer util.deepFree(alloc, updated_info);
|
|
|
|
|
|
|
|
if (!std.mem.eql(u8, credentials.password_hash, updated_info.password_hash)) return error.InvalidLogin;
|
|
|
|
}
|
|
|
|
|
|
|
|
try tx.createToken(alloc, credentials.account_id, token_hash);
|
|
|
|
|
|
|
|
try tx.commitTx();
|
2023-01-03 01:17:42 +00:00
|
|
|
const info = try svcs.getTokenByHash(alloc, token_hash, community_id);
|
2023-01-02 20:38:42 +00:00
|
|
|
defer util.deepFree(alloc, info);
|
|
|
|
|
|
|
|
return .{
|
|
|
|
.value = token,
|
|
|
|
.info = .{
|
2023-01-04 19:03:23 +00:00
|
|
|
.account_id = info.account_id,
|
2023-01-02 20:38:42 +00:00
|
|
|
.issued_at = info.issued_at,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-01-02 00:28:36 +00:00
|
|
|
// We use scrypt, a password hashing algorithm that attempts to slow down
|
|
|
|
// GPU-based cracking approaches by using large amounts of memory, for
|
|
|
|
// password hashing.
|
|
|
|
// Attempting to calculate/verify a hash will use about 50mb of work space.
|
|
|
|
const scrypt = std.crypto.pwhash.scrypt;
|
2023-01-08 23:35:58 +00:00
|
|
|
const max_password_hash_len = 128;
|
2023-01-02 00:28:36 +00:00
|
|
|
fn verifyPassword(
|
|
|
|
hash: []const u8,
|
|
|
|
password: []const u8,
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
) !void {
|
|
|
|
scrypt.strVerify(
|
|
|
|
hash,
|
|
|
|
password,
|
|
|
|
.{ .allocator = alloc },
|
|
|
|
) catch |err| return switch (err) {
|
2023-01-08 23:35:58 +00:00
|
|
|
error.PasswordVerificationFailed => return error.InvalidLogin,
|
|
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
|
|
else => |e| return e,
|
2023-01-02 00:28:36 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
const scrypt_params = if (!@import("builtin").is_test)
|
|
|
|
scrypt.Params.interactive
|
|
|
|
else
|
|
|
|
scrypt.Params{
|
|
|
|
.ln = 8,
|
|
|
|
.r = 8,
|
|
|
|
.p = 1,
|
|
|
|
};
|
2023-01-02 01:18:05 +00:00
|
|
|
fn hashPassword(password: []const u8, alloc: std.mem.Allocator) ![]const u8 {
|
2023-01-08 23:35:58 +00:00
|
|
|
var buf: [max_password_hash_len]u8 = undefined;
|
|
|
|
const hash = try scrypt.strHash(
|
2023-01-02 01:18:05 +00:00
|
|
|
password,
|
|
|
|
.{
|
|
|
|
.allocator = alloc,
|
2023-01-08 23:35:58 +00:00
|
|
|
.params = scrypt_params,
|
2023-01-02 01:18:05 +00:00
|
|
|
.encoding = .phc,
|
|
|
|
},
|
2023-01-08 23:35:58 +00:00
|
|
|
&buf,
|
|
|
|
);
|
|
|
|
|
|
|
|
return util.deepClone(alloc, hash);
|
2023-01-02 01:18:05 +00:00
|
|
|
}
|
|
|
|
|
2023-01-02 00:28:36 +00:00
|
|
|
/// A raw token is a sequence of N random bytes, base64 encoded.
|
|
|
|
/// When the token is generated:
|
|
|
|
/// - The hash of the token is calculated by:
|
|
|
|
/// 1. Decoding the base64 text
|
|
|
|
/// 2. Calculating the SHA256 hash of this text
|
|
|
|
/// 3. Encoding the hash back as base64
|
|
|
|
/// - The b64 encoded hash is stored in the database
|
|
|
|
/// - The original token is returned to the user
|
|
|
|
/// * The user will treat it as opaque text
|
|
|
|
/// When the token is verified:
|
|
|
|
/// - The hash of the token is taken as shown above
|
|
|
|
/// - The database is scanned for a token matching this hash
|
|
|
|
/// - If none can be found, the token is invalid
|
|
|
|
const Sha256 = std.crypto.hash.sha2.Sha256;
|
|
|
|
const Base64Encoder = std.base64.standard.Encoder;
|
|
|
|
const Base64Decoder = std.base64.standard.Decoder;
|
|
|
|
const token_len = 12;
|
|
|
|
fn generateToken(alloc: std.mem.Allocator) ![]const u8 {
|
|
|
|
var token = std.mem.zeroes([token_len]u8);
|
|
|
|
std.crypto.random.bytes(&token);
|
|
|
|
|
|
|
|
const token_b64_len = Base64Encoder.calcSize(token.len);
|
|
|
|
const token_b64 = try alloc.alloc(u8, token_b64_len);
|
|
|
|
return Base64Encoder.encode(token_b64, &token);
|
|
|
|
}
|
|
|
|
|
|
|
|
fn hashToken(token_b64: []const u8, alloc: std.mem.Allocator) ![]const u8 {
|
|
|
|
const decoded_token_len = Base64Decoder.calcSizeForSlice(token_b64) catch return error.InvalidToken;
|
|
|
|
if (decoded_token_len != token_len) return error.InvalidToken;
|
|
|
|
|
|
|
|
var token = std.mem.zeroes([token_len]u8);
|
|
|
|
Base64Decoder.decode(&token, token_b64) catch return error.InvalidToken;
|
|
|
|
|
|
|
|
var hash = std.mem.zeroes([Sha256.digest_length]u8);
|
|
|
|
Sha256.hash(&token, &hash, .{});
|
|
|
|
|
|
|
|
const hash_b64_len = Base64Encoder.calcSize(hash.len);
|
|
|
|
const hash_b64 = try alloc.alloc(u8, hash_b64_len);
|
|
|
|
return Base64Encoder.encode(hash_b64, &hash);
|
|
|
|
}
|
2022-12-26 16:05:26 +00:00
|
|
|
|
|
|
|
test "register" {
|
2023-01-08 23:35:58 +00:00
|
|
|
const testCase = struct {
|
|
|
|
const test_invite_code = "xyz";
|
|
|
|
const test_invite_id = Uuid.parse("d24e7f2a-7e6e-4e2a-8e9d-987538a04a40") catch unreachable;
|
|
|
|
const test_acc_id = Uuid.parse("e8e21e1d-7b80-4e48-876d-9929326af511") catch unreachable;
|
|
|
|
const test_community_id = Uuid.parse("8bf88bd7-fb07-492d-a89a-6350c036183f") catch unreachable;
|
|
|
|
|
|
|
|
const Args = struct {
|
|
|
|
username: []const u8 = "username",
|
|
|
|
password: []const u8 = "password1234",
|
|
|
|
|
|
|
|
use_invite: bool = false,
|
|
|
|
invite_community_id: Uuid = test_community_id,
|
|
|
|
invite_kind: services.invites.Kind = .user,
|
|
|
|
invite_max_uses: ?usize = null,
|
|
|
|
invite_current_uses: usize = 0,
|
|
|
|
invite_expires_at: ?DateTime = null,
|
|
|
|
|
|
|
|
get_invite_error: ?anyerror = null,
|
|
|
|
create_account_error: ?anyerror = null,
|
|
|
|
create_actor_error: ?anyerror = null,
|
|
|
|
transfer_error: ?anyerror = null,
|
|
|
|
|
|
|
|
expect_error: ?anyerror = null,
|
|
|
|
expect_transferred: bool = false,
|
2022-12-26 16:05:26 +00:00
|
|
|
};
|
2023-01-03 01:17:42 +00:00
|
|
|
|
2023-01-08 23:35:58 +00:00
|
|
|
fn runCaseOnce(allocator: std.mem.Allocator, test_args: Args) anyerror!void {
|
|
|
|
const Svc = struct {
|
|
|
|
test_args: Args,
|
|
|
|
tx_level: usize = 0,
|
|
|
|
rolled_back: bool = false,
|
|
|
|
committed: bool = false,
|
|
|
|
|
|
|
|
account_created: bool = false,
|
|
|
|
actor_created: bool = false,
|
|
|
|
community_transferred: bool = false,
|
|
|
|
|
|
|
|
fn beginTx(self: *@This()) !*@This() {
|
|
|
|
self.tx_level += 1;
|
|
|
|
return self;
|
|
|
|
}
|
|
|
|
fn rollbackTx(self: *@This()) void {
|
|
|
|
self.tx_level -= 1;
|
|
|
|
self.rolled_back = true;
|
|
|
|
}
|
|
|
|
fn commitTx(self: *@This()) !void {
|
|
|
|
self.tx_level -= 1;
|
|
|
|
self.committed = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
fn getInviteByCode(self: *@This(), alloc: Allocator, code: []const u8, community_id: Uuid) anyerror!services.invites.Invite {
|
|
|
|
try std.testing.expect(self.tx_level > 0);
|
|
|
|
try std.testing.expectEqualStrings(test_invite_code, code);
|
|
|
|
try std.testing.expectEqual(test_community_id, community_id);
|
|
|
|
if (self.test_args.get_invite_error) |err| return err;
|
|
|
|
return try util.deepClone(alloc, std.mem.zeroInit(services.invites.Invite, .{
|
|
|
|
.id = test_invite_id,
|
|
|
|
.community_id = self.test_args.invite_community_id,
|
|
|
|
.code = code,
|
|
|
|
.kind = self.test_args.invite_kind,
|
|
|
|
|
|
|
|
.times_used = self.test_args.invite_current_uses,
|
|
|
|
.max_uses = self.test_args.invite_max_uses,
|
|
|
|
.expires_at = self.test_args.invite_expires_at,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
fn createActor(self: *@This(), _: Allocator, username: []const u8, community_id: Uuid, _: bool) anyerror!Uuid {
|
|
|
|
try std.testing.expect(self.tx_level > 0);
|
|
|
|
if (self.test_args.create_actor_error) |err| return err;
|
|
|
|
try std.testing.expectEqualStrings(self.test_args.username, username);
|
|
|
|
try std.testing.expectEqual(test_community_id, community_id);
|
|
|
|
self.actor_created = true;
|
|
|
|
return test_acc_id;
|
|
|
|
}
|
|
|
|
|
|
|
|
fn createAccount(self: *@This(), alloc: Allocator, args: services.accounts.CreateArgs) anyerror!void {
|
|
|
|
try std.testing.expect(self.tx_level > 0);
|
|
|
|
if (self.test_args.create_account_error) |err| return err;
|
|
|
|
try verifyPassword(args.password_hash, self.test_args.password, alloc);
|
|
|
|
if (self.test_args.use_invite)
|
|
|
|
try std.testing.expectEqual(@as(?Uuid, test_invite_id), args.invite_id)
|
|
|
|
else
|
|
|
|
try std.testing.expect(args.invite_id == null);
|
|
|
|
|
|
|
|
try std.testing.expectEqual(services.accounts.Role.user, args.role);
|
|
|
|
self.account_created = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
fn transferCommunityOwnership(self: *@This(), community_id: Uuid, account_id: Uuid) !void {
|
|
|
|
try std.testing.expect(self.tx_level > 0);
|
|
|
|
if (self.test_args.transfer_error) |err| return err;
|
|
|
|
self.community_transferred = true;
|
|
|
|
try std.testing.expectEqual(test_community_id, community_id);
|
|
|
|
try std.testing.expectEqual(test_acc_id, account_id);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
var svc = Svc{ .test_args = test_args };
|
|
|
|
|
|
|
|
const community = std.mem.zeroInit(pkg.Community, .{ .kind = .local, .id = test_community_id });
|
|
|
|
|
|
|
|
const result = register(
|
|
|
|
allocator,
|
|
|
|
.{ .community = community },
|
|
|
|
&svc,
|
|
|
|
.{
|
|
|
|
.username = test_args.username,
|
|
|
|
.password = test_args.password,
|
|
|
|
.invite_code = if (test_args.use_invite) test_invite_code else null,
|
|
|
|
},
|
|
|
|
// shortcut out of memory errors to test allocation
|
|
|
|
) catch |err| if (err == error.OutOfMemory) return err else err;
|
|
|
|
|
|
|
|
if (test_args.expect_error) |err| {
|
|
|
|
try std.testing.expectError(err, result);
|
|
|
|
try std.testing.expect(!svc.committed);
|
|
|
|
if (svc.account_created or svc.actor_created or svc.community_transferred) {
|
|
|
|
try std.testing.expect(svc.rolled_back);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
try std.testing.expectEqual(test_acc_id, try result);
|
|
|
|
try std.testing.expect(svc.committed);
|
|
|
|
try std.testing.expect(!svc.rolled_back);
|
|
|
|
try std.testing.expect(svc.account_created);
|
|
|
|
try std.testing.expect(svc.actor_created);
|
|
|
|
try std.testing.expectEqual(test_args.expect_transferred, svc.community_transferred);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn case(args: Args) !void {
|
|
|
|
try std.testing.checkAllAllocationFailures(std.testing.allocator, runCaseOnce, .{args});
|
|
|
|
}
|
|
|
|
}.case;
|
|
|
|
|
|
|
|
// regular registration
|
|
|
|
try testCase(.{});
|
|
|
|
|
|
|
|
// registration with invite
|
|
|
|
try testCase(.{ .use_invite = true });
|
|
|
|
|
|
|
|
// registration with invite for a different community
|
|
|
|
try testCase(.{
|
|
|
|
.invite_community_id = Uuid.parse("11111111-1111-1111-1111-111111111111") catch unreachable,
|
|
|
|
.use_invite = true,
|
|
|
|
.expect_error = error.WrongCommunity,
|
|
|
|
});
|
|
|
|
|
|
|
|
// registration as a new community owner
|
|
|
|
try testCase(.{
|
|
|
|
.use_invite = true,
|
|
|
|
.invite_kind = .community_owner,
|
|
|
|
.expect_transferred = true,
|
|
|
|
});
|
|
|
|
|
|
|
|
// invite with expiration info
|
|
|
|
try testCase(.{
|
|
|
|
.use_invite = true,
|
|
|
|
.invite_max_uses = 100,
|
|
|
|
.invite_current_uses = 10,
|
|
|
|
.invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp + 3600 },
|
|
|
|
});
|
|
|
|
|
|
|
|
// missing invite
|
|
|
|
try testCase(.{
|
|
|
|
.use_invite = true,
|
|
|
|
.get_invite_error = error.NotFound,
|
|
|
|
.expect_error = error.InvalidInvite,
|
|
|
|
});
|
|
|
|
|
|
|
|
// expired invite
|
|
|
|
try testCase(.{
|
|
|
|
.use_invite = true,
|
|
|
|
.invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp - 3600 },
|
|
|
|
.expect_error = error.InvalidInvite,
|
|
|
|
});
|
|
|
|
|
|
|
|
// used invite
|
|
|
|
try testCase(.{
|
|
|
|
.use_invite = true,
|
|
|
|
.invite_max_uses = 100,
|
|
|
|
.invite_current_uses = 110,
|
|
|
|
.expect_error = error.InvalidInvite,
|
|
|
|
});
|
2022-12-26 16:05:26 +00:00
|
|
|
}
|